1use anyhow::{anyhow, Result};
90use fresh_core::api::{
91 ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
92 GrammarInfoSnapshot, JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions,
93 PluginCommand, PluginResponse,
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
111fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
113 std::fs::create_dir_all(dst)?;
114 for entry in std::fs::read_dir(src)? {
115 let entry = entry?;
116 let file_type = entry.file_type()?;
117 let src_path = entry.path();
118 let dst_path = dst.join(entry.file_name());
119 if file_type.is_dir() {
120 copy_dir_recursive(&src_path, &dst_path)?;
121 } else {
122 std::fs::copy(&src_path, &dst_path)?;
123 }
124 }
125 Ok(())
126}
127
128#[allow(clippy::only_used_in_recursion)]
130fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
131 use rquickjs::Type;
132 match val.type_of() {
133 Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
134 Type::Bool => val
135 .as_bool()
136 .map(serde_json::Value::Bool)
137 .unwrap_or(serde_json::Value::Null),
138 Type::Int => val
139 .as_int()
140 .map(|n| serde_json::Value::Number(n.into()))
141 .unwrap_or(serde_json::Value::Null),
142 Type::Float => val
143 .as_float()
144 .map(|f| {
145 if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
148 serde_json::Value::Number((f as i64).into())
149 } else {
150 serde_json::Number::from_f64(f)
151 .map(serde_json::Value::Number)
152 .unwrap_or(serde_json::Value::Null)
153 }
154 })
155 .unwrap_or(serde_json::Value::Null),
156 Type::String => val
157 .as_string()
158 .and_then(|s| s.to_string().ok())
159 .map(serde_json::Value::String)
160 .unwrap_or(serde_json::Value::Null),
161 Type::Array => {
162 if let Some(arr) = val.as_array() {
163 let items: Vec<serde_json::Value> = arr
164 .iter()
165 .filter_map(|item| item.ok())
166 .map(|item| js_to_json(ctx, item))
167 .collect();
168 serde_json::Value::Array(items)
169 } else {
170 serde_json::Value::Null
171 }
172 }
173 Type::Object | Type::Constructor | Type::Function => {
174 if let Some(obj) = val.as_object() {
175 let mut map = serde_json::Map::new();
176 for key in obj.keys::<String>().flatten() {
177 if let Ok(v) = obj.get::<_, Value>(&key) {
178 map.insert(key, js_to_json(ctx, v));
179 }
180 }
181 serde_json::Value::Object(map)
182 } else {
183 serde_json::Value::Null
184 }
185 }
186 _ => serde_json::Value::Null,
187 }
188}
189
190fn json_to_js_value<'js>(
192 ctx: &rquickjs::Ctx<'js>,
193 val: &serde_json::Value,
194) -> rquickjs::Result<Value<'js>> {
195 match val {
196 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
197 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
198 serde_json::Value::Number(n) => {
199 if let Some(i) = n.as_i64() {
200 Ok(Value::new_int(ctx.clone(), i as i32))
201 } else if let Some(f) = n.as_f64() {
202 Ok(Value::new_float(ctx.clone(), f))
203 } else {
204 Ok(Value::new_null(ctx.clone()))
205 }
206 }
207 serde_json::Value::String(s) => {
208 let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
209 Ok(js_str.into_value())
210 }
211 serde_json::Value::Array(arr) => {
212 let js_arr = rquickjs::Array::new(ctx.clone())?;
213 for (i, item) in arr.iter().enumerate() {
214 let js_val = json_to_js_value(ctx, item)?;
215 js_arr.set(i, js_val)?;
216 }
217 Ok(js_arr.into_value())
218 }
219 serde_json::Value::Object(map) => {
220 let obj = rquickjs::Object::new(ctx.clone())?;
221 for (key, val) in map {
222 let js_val = json_to_js_value(ctx, val)?;
223 obj.set(key.as_str(), js_val)?;
224 }
225 Ok(obj.into_value())
226 }
227 }
228}
229
230fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
233 let js_data = match json_to_js_value(ctx, event_data) {
234 Ok(v) => v,
235 Err(e) => {
236 log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
237 return;
238 }
239 };
240
241 let globals = ctx.globals();
242 let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
243 return;
244 };
245
246 match func.call::<_, rquickjs::Value>((js_data,)) {
247 Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
248 Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
249 }
250
251 run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
252}
253
254fn attach_promise_catch<'js>(
256 ctx: &rquickjs::Ctx<'js>,
257 globals: &rquickjs::Object<'js>,
258 handler_name: &str,
259 result: rquickjs::Value<'js>,
260) {
261 let Some(obj) = result.as_object() else {
262 return;
263 };
264 if obj.get::<_, rquickjs::Function>("then").is_err() {
265 return;
266 }
267 let _ = globals.set("__pendingPromise", result);
268 let catch_code = format!(
269 r#"globalThis.__pendingPromise.catch(function(e) {{
270 console.error('Handler {} async error:', e);
271 throw e;
272 }}); delete globalThis.__pendingPromise;"#,
273 handler_name
274 );
275 let _ = ctx.eval::<(), _>(catch_code.as_bytes());
276}
277
278fn get_text_properties_at_cursor_typed(
280 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
281 buffer_id: u32,
282) -> fresh_core::api::TextPropertiesAtCursor {
283 use fresh_core::api::TextPropertiesAtCursor;
284
285 let snap = match snapshot.read() {
286 Ok(s) => s,
287 Err(_) => return TextPropertiesAtCursor(Vec::new()),
288 };
289 let buffer_id_typed = BufferId(buffer_id as usize);
290 let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied();
291 let fallback_pos = if snap.active_buffer_id == buffer_id_typed {
292 snap.primary_cursor.as_ref().map(|c| c.position)
293 } else {
294 None
295 };
296 let cursor_pos = match snapshot_pos.or(fallback_pos) {
297 Some(pos) => pos,
298 None => {
299 tracing::debug!(
300 "getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})",
301 buffer_id_typed,
302 snapshot_pos,
303 snap.active_buffer_id
304 );
305 return TextPropertiesAtCursor(Vec::new());
306 }
307 };
308
309 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
310 Some(p) => p,
311 None => {
312 tracing::debug!(
313 "getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})",
314 buffer_id_typed,
315 cursor_pos
316 );
317 return TextPropertiesAtCursor(Vec::new());
318 }
319 };
320
321 let result: Vec<_> = properties
322 .iter()
323 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
324 .map(|prop| prop.properties.clone())
325 .collect();
326
327 tracing::debug!(
328 "getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}",
329 buffer_id_typed,
330 cursor_pos,
331 snapshot_pos,
332 fallback_pos,
333 snap.active_buffer_id,
334 properties.len(),
335 result.len()
336 );
337
338 TextPropertiesAtCursor(result)
339}
340
341fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
343 use rquickjs::Type;
344 match val.type_of() {
345 Type::Null => "null".to_string(),
346 Type::Undefined => "undefined".to_string(),
347 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
348 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
349 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
350 Type::String => val
351 .as_string()
352 .and_then(|s| s.to_string().ok())
353 .unwrap_or_default(),
354 Type::Object | Type::Exception => {
355 if let Some(obj) = val.as_object() {
357 let name: Option<String> = obj.get("name").ok();
359 let message: Option<String> = obj.get("message").ok();
360 let stack: Option<String> = obj.get("stack").ok();
361
362 if message.is_some() || name.is_some() {
363 let name = name.unwrap_or_else(|| "Error".to_string());
365 let message = message.unwrap_or_default();
366 if let Some(stack) = stack {
367 return format!("{}: {}\n{}", name, message, stack);
368 } else {
369 return format!("{}: {}", name, message);
370 }
371 }
372
373 let json = js_to_json(ctx, val.clone());
375 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
376 } else {
377 "[object]".to_string()
378 }
379 }
380 Type::Array => {
381 let json = js_to_json(ctx, val.clone());
382 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
383 }
384 Type::Function | Type::Constructor => "[function]".to_string(),
385 Type::Symbol => "[symbol]".to_string(),
386 Type::BigInt => val
387 .as_big_int()
388 .and_then(|b| b.clone().to_i64().ok())
389 .map(|n| n.to_string())
390 .unwrap_or_else(|| "[bigint]".to_string()),
391 _ => format!("[{}]", val.type_name()),
392 }
393}
394
395fn format_js_error(
397 ctx: &rquickjs::Ctx<'_>,
398 err: rquickjs::Error,
399 source_name: &str,
400) -> anyhow::Error {
401 if err.is_exception() {
403 let exc = ctx.catch();
405 if !exc.is_undefined() && !exc.is_null() {
406 if let Some(exc_obj) = exc.as_object() {
408 let message: String = exc_obj
409 .get::<_, String>("message")
410 .unwrap_or_else(|_| "Unknown error".to_string());
411 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
412 let name: String = exc_obj
413 .get::<_, String>("name")
414 .unwrap_or_else(|_| "Error".to_string());
415
416 if !stack.is_empty() {
417 return anyhow::anyhow!(
418 "JS error in {}: {}: {}\nStack trace:\n{}",
419 source_name,
420 name,
421 message,
422 stack
423 );
424 } else {
425 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
426 }
427 } else {
428 let exc_str: String = exc
430 .as_string()
431 .and_then(|s: &rquickjs::String| s.to_string().ok())
432 .unwrap_or_else(|| format!("{:?}", exc));
433 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
434 }
435 }
436 }
437
438 anyhow::anyhow!("JS error in {}: {}", source_name, err)
440}
441
442fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
445 let error = format_js_error(ctx, err, context);
446 tracing::error!("{}", error);
447
448 if should_panic_on_js_errors() {
450 panic!("JavaScript error in {}: {}", context, error);
451 }
452}
453
454static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
456 std::sync::atomic::AtomicBool::new(false);
457
458pub fn set_panic_on_js_errors(enabled: bool) {
460 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
461}
462
463fn should_panic_on_js_errors() -> bool {
465 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
466}
467
468static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
472
473static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
475
476fn set_fatal_js_error(msg: String) {
478 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
479 if guard.is_none() {
480 *guard = Some(msg);
482 }
483 }
484 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
485}
486
487pub fn has_fatal_js_error() -> bool {
489 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
490}
491
492pub fn take_fatal_js_error() -> Option<String> {
494 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
495 return None;
496 }
497 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
498 guard.take()
499 } else {
500 Some("Fatal JS error (message unavailable)".to_string())
501 }
502}
503
504fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
507 let mut count = 0;
508 loop {
509 let exc: rquickjs::Value = ctx.catch();
511 if exc.is_exception() {
513 let error_msg = if let Some(err) = exc.as_exception() {
514 format!(
515 "{}: {}",
516 err.message().unwrap_or_default(),
517 err.stack().unwrap_or_default()
518 )
519 } else {
520 format!("{:?}", exc)
521 };
522 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
523 if should_panic_on_js_errors() {
524 panic!("Unhandled JS exception during {}: {}", context, error_msg);
525 }
526 }
527
528 if !ctx.execute_pending_job() {
529 break;
530 }
531 count += 1;
532 }
533
534 let exc: rquickjs::Value = ctx.catch();
536 if exc.is_exception() {
537 let error_msg = if let Some(err) = exc.as_exception() {
538 format!(
539 "{}: {}",
540 err.message().unwrap_or_default(),
541 err.stack().unwrap_or_default()
542 )
543 } else {
544 format!("{:?}", exc)
545 };
546 tracing::error!(
547 "Unhandled JS exception after running jobs in {}: {}",
548 context,
549 error_msg
550 );
551 if should_panic_on_js_errors() {
552 panic!(
553 "Unhandled JS exception after running jobs in {}: {}",
554 context, error_msg
555 );
556 }
557 }
558
559 count
560}
561
562fn parse_text_property_entry(
564 ctx: &rquickjs::Ctx<'_>,
565 obj: &Object<'_>,
566) -> Option<TextPropertyEntry> {
567 let text: String = obj.get("text").ok()?;
568 let properties: HashMap<String, serde_json::Value> = obj
569 .get::<_, Object>("properties")
570 .ok()
571 .map(|props_obj| {
572 let mut map = HashMap::new();
573 for key in props_obj.keys::<String>().flatten() {
574 if let Ok(v) = props_obj.get::<_, Value>(&key) {
575 map.insert(key, js_to_json(ctx, v));
576 }
577 }
578 map
579 })
580 .unwrap_or_default();
581
582 let style: Option<fresh_core::api::OverlayOptions> =
584 obj.get::<_, Object>("style").ok().and_then(|style_obj| {
585 let json_val = js_to_json(ctx, Value::from_object(style_obj));
586 serde_json::from_value(json_val).ok()
587 });
588
589 let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
591 .get::<_, rquickjs::Array>("inlineOverlays")
592 .ok()
593 .map(|arr| {
594 arr.iter::<Object>()
595 .flatten()
596 .filter_map(|item| {
597 let json_val = js_to_json(ctx, Value::from_object(item));
598 serde_json::from_value(json_val).ok()
599 })
600 .collect()
601 })
602 .unwrap_or_default();
603
604 Some(TextPropertyEntry {
605 text,
606 properties,
607 style,
608 inline_overlays,
609 })
610}
611
612pub type PendingResponses =
614 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
615
616#[derive(Debug, Clone)]
618pub struct TsPluginInfo {
619 pub name: String,
620 pub path: PathBuf,
621 pub enabled: bool,
622 pub declarations: Option<String>,
629}
630
631#[derive(Debug, Clone, Default)]
637pub struct PluginTrackedState {
638 pub overlay_namespaces: Vec<(BufferId, String)>,
640 pub virtual_line_namespaces: Vec<(BufferId, String)>,
642 pub line_indicator_namespaces: Vec<(BufferId, String)>,
644 pub virtual_text_ids: Vec<(BufferId, String)>,
646 pub file_explorer_namespaces: Vec<String>,
648 pub contexts_set: Vec<String>,
650 pub background_process_ids: Vec<u64>,
653 pub scroll_sync_group_ids: Vec<u32>,
655 pub virtual_buffer_ids: Vec<BufferId>,
657 pub composite_buffer_ids: Vec<BufferId>,
659 pub terminal_ids: Vec<fresh_core::TerminalId>,
661}
662
663pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
668
669#[derive(Debug, Clone)]
670pub struct PluginHandler {
671 pub plugin_name: String,
672 pub handler_name: String,
673}
674
675fn parse_animation_rect(
678 obj: &rquickjs::Object<'_>,
679) -> rquickjs::Result<fresh_core::api::AnimationRect> {
680 Ok(fresh_core::api::AnimationRect {
681 x: obj.get::<_, u16>("x").unwrap_or(0),
682 y: obj.get::<_, u16>("y").unwrap_or(0),
683 width: obj.get::<_, u16>("width").unwrap_or(0),
684 height: obj.get::<_, u16>("height").unwrap_or(0),
685 })
686}
687
688fn parse_animation_kind(
692 obj: &rquickjs::Object<'_>,
693) -> rquickjs::Result<fresh_core::api::PluginAnimationKind> {
694 use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
695 let kind: String = obj.get::<_, String>("kind").unwrap_or_default();
696 match kind.as_str() {
697 "slideIn" | "" => {
698 let from_str: String = obj.get::<_, String>("from").unwrap_or_default();
699 let from = match from_str.as_str() {
700 "top" => PluginAnimationEdge::Top,
701 "left" => PluginAnimationEdge::Left,
702 "right" => PluginAnimationEdge::Right,
703 _ => PluginAnimationEdge::Bottom,
704 };
705 let duration_ms: u32 = obj.get::<_, u32>("durationMs").unwrap_or(300);
706 let delay_ms: u32 = obj.get::<_, u32>("delayMs").unwrap_or(0);
707 Ok(PluginAnimationKind::SlideIn {
708 from,
709 duration_ms,
710 delay_ms,
711 })
712 }
713 other => Err(rquickjs::Error::new_from_js_message(
714 "string",
715 "PluginAnimationKind",
716 format!("unknown animation kind: {}", other),
717 )),
718 }
719}
720
721#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
724#[rquickjs::class]
725pub struct JsEditorApi {
726 #[qjs(skip_trace)]
727 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
728 #[qjs(skip_trace)]
729 command_sender: mpsc::Sender<PluginCommand>,
730 #[qjs(skip_trace)]
731 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
732 #[qjs(skip_trace)]
733 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
734 #[qjs(skip_trace)]
735 next_request_id: Rc<RefCell<u64>>,
736 #[qjs(skip_trace)]
737 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
738 #[qjs(skip_trace)]
739 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
740 #[qjs(skip_trace)]
741 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
742 #[qjs(skip_trace)]
743 async_resource_owners: AsyncResourceOwners,
744 #[qjs(skip_trace)]
746 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
747 #[qjs(skip_trace)]
749 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
750 #[qjs(skip_trace)]
752 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
753 #[qjs(skip_trace)]
755 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
756 #[qjs(skip_trace)]
760 plugin_api_exports:
761 Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>,
762 pub plugin_name: String,
763}
764
765#[plugin_api_impl]
766#[rquickjs::methods(rename_all = "camelCase")]
767impl JsEditorApi {
768 pub fn api_version(&self) -> u32 {
773 2
774 }
775
776 pub fn plugin_name(&self) -> String {
780 self.plugin_name.clone()
781 }
782
783 #[plugin_api(ts_return = "boolean")]
793 pub fn export_plugin_api<'js>(
794 &self,
795 ctx: rquickjs::Ctx<'js>,
796 name: String,
797 api: rquickjs::Value<'js>,
798 ) -> rquickjs::Result<bool> {
799 if name.is_empty() {
800 let msg =
801 rquickjs::String::from_str(ctx.clone(), "exportPluginApi: name must be non-empty")?;
802 return Err(ctx.throw(msg.into_value()));
803 }
804 let obj = match api.as_object() {
805 Some(o) => o.clone(),
806 None => {
807 let msg = rquickjs::String::from_str(
808 ctx.clone(),
809 "exportPluginApi: api must be an object",
810 )?;
811 return Err(ctx.throw(msg.into_value()));
812 }
813 };
814 let persistent = rquickjs::Persistent::save(&ctx, obj);
815 self.plugin_api_exports
816 .borrow_mut()
817 .insert(name, (self.plugin_name.clone(), persistent));
818 Ok(true)
819 }
820
821 #[plugin_api(ts_return = "unknown | null")]
825 pub fn get_plugin_api<'js>(
826 &self,
827 ctx: rquickjs::Ctx<'js>,
828 name: String,
829 ) -> rquickjs::Result<rquickjs::Value<'js>> {
830 let persistent = self
831 .plugin_api_exports
832 .borrow()
833 .get(&name)
834 .map(|(_exporter, p)| p.clone());
835 match persistent {
836 Some(p) => {
837 let restored = p.restore(&ctx)?;
838 Ok(restored.into_value())
839 }
840 None => Ok(rquickjs::Value::new_null(ctx)),
841 }
842 }
843
844 pub fn get_active_buffer_id(&self) -> u32 {
846 self.state_snapshot
847 .read()
848 .map(|s| s.active_buffer_id.0 as u32)
849 .unwrap_or(0)
850 }
851
852 pub fn get_active_split_id(&self) -> u32 {
854 self.state_snapshot
855 .read()
856 .map(|s| s.active_split_id as u32)
857 .unwrap_or(0)
858 }
859
860 #[plugin_api(ts_return = "BufferInfo[]")]
862 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
863 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
864 s.buffers.values().cloned().collect()
865 } else {
866 Vec::new()
867 };
868 rquickjs_serde::to_value(ctx, &buffers)
869 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
870 }
871
872 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
874 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
875 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
876 s.available_grammars.clone()
877 } else {
878 Vec::new()
879 };
880 rquickjs_serde::to_value(ctx, &grammars)
881 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
882 }
883
884 pub fn debug(&self, msg: String) {
887 tracing::trace!("Plugin.debug: {}", msg);
888 }
889
890 pub fn info(&self, msg: String) {
891 tracing::info!("Plugin: {}", msg);
892 }
893
894 pub fn warn(&self, msg: String) {
895 tracing::warn!("Plugin: {}", msg);
896 }
897
898 pub fn error(&self, msg: String) {
899 tracing::error!("Plugin: {}", msg);
900 }
901
902 pub fn set_status(&self, msg: String) {
905 let _ = self
906 .command_sender
907 .send(PluginCommand::SetStatus { message: msg });
908 }
909
910 pub fn copy_to_clipboard(&self, text: String) {
913 let _ = self
914 .command_sender
915 .send(PluginCommand::SetClipboard { text });
916 }
917
918 pub fn set_clipboard(&self, text: String) {
919 let _ = self
920 .command_sender
921 .send(PluginCommand::SetClipboard { text });
922 }
923
924 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
929 if let Some(mode_name) = mode {
930 let key = format!("{}\0{}", action, mode_name);
931 if let Ok(snapshot) = self.state_snapshot.read() {
932 return snapshot.keybinding_labels.get(&key).cloned();
933 }
934 }
935 None
936 }
937
938 pub fn register_command<'js>(
949 &self,
950 ctx: rquickjs::Ctx<'js>,
951 name: String,
952 description: String,
953 handler_name: String,
954 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
955 rquickjs::Value<'js>,
956 >,
957 ) -> rquickjs::Result<bool> {
958 let plugin_name = self.plugin_name.clone();
960 let context_str: Option<String> = context.0.and_then(|v| {
962 if v.is_null() || v.is_undefined() {
963 None
964 } else {
965 v.as_string().and_then(|s| s.to_string().ok())
966 }
967 });
968
969 tracing::debug!(
970 "registerCommand: plugin='{}', name='{}', handler='{}'",
971 plugin_name,
972 name,
973 handler_name
974 );
975
976 let tracking_key = if name.starts_with('%') {
980 format!("{}:{}", plugin_name, name)
981 } else {
982 name.clone()
983 };
984 {
985 let names = self.registered_command_names.borrow();
986 if let Some(existing_plugin) = names.get(&tracking_key) {
987 if existing_plugin != &plugin_name {
988 let msg = format!(
989 "Command '{}' already registered by plugin '{}'",
990 name, existing_plugin
991 );
992 tracing::warn!("registerCommand collision: {}", msg);
993 return Err(
994 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
995 );
996 }
997 }
999 }
1000
1001 self.registered_command_names
1003 .borrow_mut()
1004 .insert(tracking_key, plugin_name.clone());
1005
1006 self.registered_actions.borrow_mut().insert(
1008 handler_name.clone(),
1009 PluginHandler {
1010 plugin_name: self.plugin_name.clone(),
1011 handler_name: handler_name.clone(),
1012 },
1013 );
1014
1015 let command = Command {
1017 name: name.clone(),
1018 description,
1019 action_name: handler_name,
1020 plugin_name,
1021 custom_contexts: context_str.into_iter().collect(),
1022 };
1023
1024 Ok(self
1025 .command_sender
1026 .send(PluginCommand::RegisterCommand { command })
1027 .is_ok())
1028 }
1029
1030 pub fn unregister_command(&self, name: String) -> bool {
1032 let tracking_key = if name.starts_with('%') {
1035 format!("{}:{}", self.plugin_name, name)
1036 } else {
1037 name.clone()
1038 };
1039 self.registered_command_names
1040 .borrow_mut()
1041 .remove(&tracking_key);
1042 self.command_sender
1043 .send(PluginCommand::UnregisterCommand { name })
1044 .is_ok()
1045 }
1046
1047 pub fn set_context(&self, name: String, active: bool) -> bool {
1049 if active {
1051 self.plugin_tracked_state
1052 .borrow_mut()
1053 .entry(self.plugin_name.clone())
1054 .or_default()
1055 .contexts_set
1056 .push(name.clone());
1057 }
1058 self.command_sender
1059 .send(PluginCommand::SetContext { name, active })
1060 .is_ok()
1061 }
1062
1063 pub fn execute_action(&self, action_name: String) -> bool {
1065 self.command_sender
1066 .send(PluginCommand::ExecuteAction { action_name })
1067 .is_ok()
1068 }
1069
1070 pub fn t<'js>(
1075 &self,
1076 _ctx: rquickjs::Ctx<'js>,
1077 key: String,
1078 args: rquickjs::function::Rest<Value<'js>>,
1079 ) -> String {
1080 let plugin_name = self.plugin_name.clone();
1082 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1084 if let Some(obj) = first_arg.as_object() {
1085 let mut map = HashMap::new();
1086 for k in obj.keys::<String>().flatten() {
1087 if let Ok(v) = obj.get::<_, String>(&k) {
1088 map.insert(k, v);
1089 }
1090 }
1091 map
1092 } else {
1093 HashMap::new()
1094 }
1095 } else {
1096 HashMap::new()
1097 };
1098 let res = self.services.translate(&plugin_name, &key, &args_map);
1099
1100 tracing::info!(
1101 "Translating: key={}, plugin={}, args={:?} => res='{}'",
1102 key,
1103 plugin_name,
1104 args_map,
1105 res
1106 );
1107 res
1108 }
1109
1110 pub fn get_cursor_position(&self) -> u32 {
1114 self.state_snapshot
1115 .read()
1116 .ok()
1117 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1118 .unwrap_or(0)
1119 }
1120
1121 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1123 if let Ok(s) = self.state_snapshot.read() {
1124 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1125 if let Some(p) = &b.path {
1126 return p.to_string_lossy().to_string();
1127 }
1128 }
1129 }
1130 String::new()
1131 }
1132
1133 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1135 if let Ok(s) = self.state_snapshot.read() {
1136 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1137 return b.length as u32;
1138 }
1139 }
1140 0
1141 }
1142
1143 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1145 if let Ok(s) = self.state_snapshot.read() {
1146 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1147 return b.modified;
1148 }
1149 }
1150 false
1151 }
1152
1153 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1156 self.command_sender
1157 .send(PluginCommand::SaveBufferToPath {
1158 buffer_id: BufferId(buffer_id as usize),
1159 path: std::path::PathBuf::from(path),
1160 })
1161 .is_ok()
1162 }
1163
1164 #[plugin_api(ts_return = "BufferInfo | null")]
1166 pub fn get_buffer_info<'js>(
1167 &self,
1168 ctx: rquickjs::Ctx<'js>,
1169 buffer_id: u32,
1170 ) -> rquickjs::Result<Value<'js>> {
1171 let info = if let Ok(s) = self.state_snapshot.read() {
1172 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1173 } else {
1174 None
1175 };
1176 rquickjs_serde::to_value(ctx, &info)
1177 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1178 }
1179
1180 #[plugin_api(ts_return = "CursorInfo | null")]
1182 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1183 let cursor = if let Ok(s) = self.state_snapshot.read() {
1184 s.primary_cursor.clone()
1185 } else {
1186 None
1187 };
1188 rquickjs_serde::to_value(ctx, &cursor)
1189 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1190 }
1191
1192 #[plugin_api(ts_return = "CursorInfo[]")]
1194 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1195 let cursors = if let Ok(s) = self.state_snapshot.read() {
1196 s.all_cursors.clone()
1197 } else {
1198 Vec::new()
1199 };
1200 rquickjs_serde::to_value(ctx, &cursors)
1201 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1202 }
1203
1204 #[plugin_api(ts_return = "number[]")]
1206 pub fn get_all_cursor_positions<'js>(
1207 &self,
1208 ctx: rquickjs::Ctx<'js>,
1209 ) -> rquickjs::Result<Value<'js>> {
1210 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1211 s.all_cursors.iter().map(|c| c.position as u32).collect()
1212 } else {
1213 Vec::new()
1214 };
1215 rquickjs_serde::to_value(ctx, &positions)
1216 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1217 }
1218
1219 #[plugin_api(ts_return = "ViewportInfo | null")]
1221 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1222 let viewport = if let Ok(s) = self.state_snapshot.read() {
1223 s.viewport.clone()
1224 } else {
1225 None
1226 };
1227 rquickjs_serde::to_value(ctx, &viewport)
1228 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1229 }
1230
1231 #[plugin_api(ts_return = "SplitSnapshot[]")]
1238 pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1239 let splits = if let Ok(s) = self.state_snapshot.read() {
1240 s.splits.clone()
1241 } else {
1242 Vec::new()
1243 };
1244 rquickjs_serde::to_value(ctx, &splits)
1245 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1246 }
1247
1248 pub fn get_cursor_line(&self) -> u32 {
1250 0
1254 }
1255
1256 #[plugin_api(
1259 async_promise,
1260 js_name = "getLineStartPosition",
1261 ts_return = "number | null"
1262 )]
1263 #[qjs(rename = "_getLineStartPositionStart")]
1264 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1265 let id = self.alloc_request_id();
1266 let _ = self
1268 .command_sender
1269 .send(PluginCommand::GetLineStartPosition {
1270 buffer_id: BufferId(0),
1271 line,
1272 request_id: id,
1273 });
1274 id
1275 }
1276
1277 #[plugin_api(
1281 async_promise,
1282 js_name = "getLineEndPosition",
1283 ts_return = "number | null"
1284 )]
1285 #[qjs(rename = "_getLineEndPositionStart")]
1286 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1287 let id = self.alloc_request_id();
1288 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1290 buffer_id: BufferId(0),
1291 line,
1292 request_id: id,
1293 });
1294 id
1295 }
1296
1297 #[plugin_api(
1300 async_promise,
1301 js_name = "getBufferLineCount",
1302 ts_return = "number | null"
1303 )]
1304 #[qjs(rename = "_getBufferLineCountStart")]
1305 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1306 let id = self.alloc_request_id();
1307 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1309 buffer_id: BufferId(0),
1310 request_id: id,
1311 });
1312 id
1313 }
1314
1315 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1318 self.command_sender
1319 .send(PluginCommand::ScrollToLineCenter {
1320 split_id: SplitId(split_id as usize),
1321 buffer_id: BufferId(buffer_id as usize),
1322 line: line as usize,
1323 })
1324 .is_ok()
1325 }
1326
1327 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1336 self.command_sender
1337 .send(PluginCommand::ScrollBufferToLine {
1338 buffer_id: BufferId(buffer_id as usize),
1339 line: line as usize,
1340 })
1341 .is_ok()
1342 }
1343
1344 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1346 let path_buf = std::path::PathBuf::from(&path);
1347 if let Ok(s) = self.state_snapshot.read() {
1348 for (id, info) in &s.buffers {
1349 if let Some(buf_path) = &info.path {
1350 if buf_path == &path_buf {
1351 return id.0 as u32;
1352 }
1353 }
1354 }
1355 }
1356 0
1357 }
1358
1359 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1361 pub fn get_buffer_saved_diff<'js>(
1362 &self,
1363 ctx: rquickjs::Ctx<'js>,
1364 buffer_id: u32,
1365 ) -> rquickjs::Result<Value<'js>> {
1366 let diff = if let Ok(s) = self.state_snapshot.read() {
1367 s.buffer_saved_diffs
1368 .get(&BufferId(buffer_id as usize))
1369 .cloned()
1370 } else {
1371 None
1372 };
1373 rquickjs_serde::to_value(ctx, &diff)
1374 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1375 }
1376
1377 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1381 self.command_sender
1382 .send(PluginCommand::InsertText {
1383 buffer_id: BufferId(buffer_id as usize),
1384 position: position as usize,
1385 text,
1386 })
1387 .is_ok()
1388 }
1389
1390 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1392 self.command_sender
1393 .send(PluginCommand::DeleteRange {
1394 buffer_id: BufferId(buffer_id as usize),
1395 range: (start as usize)..(end as usize),
1396 })
1397 .is_ok()
1398 }
1399
1400 pub fn insert_at_cursor(&self, text: String) -> bool {
1402 self.command_sender
1403 .send(PluginCommand::InsertAtCursor { text })
1404 .is_ok()
1405 }
1406
1407 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1411 self.command_sender
1412 .send(PluginCommand::OpenFileAtLocation {
1413 path: PathBuf::from(path),
1414 line: line.map(|l| l as usize),
1415 column: column.map(|c| c as usize),
1416 })
1417 .is_ok()
1418 }
1419
1420 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1422 self.command_sender
1423 .send(PluginCommand::OpenFileInSplit {
1424 split_id: split_id as usize,
1425 path: PathBuf::from(path),
1426 line: Some(line as usize),
1427 column: Some(column as usize),
1428 })
1429 .is_ok()
1430 }
1431
1432 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1434 self.command_sender
1435 .send(PluginCommand::ShowBuffer {
1436 buffer_id: BufferId(buffer_id as usize),
1437 })
1438 .is_ok()
1439 }
1440
1441 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1443 self.command_sender
1444 .send(PluginCommand::CloseBuffer {
1445 buffer_id: BufferId(buffer_id as usize),
1446 })
1447 .is_ok()
1448 }
1449
1450 #[plugin_api(skip)]
1456 #[qjs(skip)]
1457 fn alloc_request_id(&self) -> u64 {
1458 let mut id_ref = self.next_request_id.borrow_mut();
1459 let id = *id_ref;
1460 *id_ref += 1;
1461 self.callback_contexts
1462 .borrow_mut()
1463 .insert(id, self.plugin_name.clone());
1464 id
1465 }
1466
1467 #[plugin_api(skip)]
1471 #[qjs(skip)]
1472 fn alloc_animation_id(&self) -> u64 {
1473 let mut id_ref = self.next_request_id.borrow_mut();
1474 let id = *id_ref;
1475 *id_ref += 1;
1476 id
1477 }
1478
1479 pub fn animate_area<'js>(
1482 &self,
1483 #[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
1484 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1485 ) -> rquickjs::Result<u64> {
1486 let rect = parse_animation_rect(&rect)?;
1487 let kind = parse_animation_kind(&kind)?;
1488 let id = self.alloc_animation_id();
1489 let _ = self
1490 .command_sender
1491 .send(PluginCommand::StartAnimationArea { id, rect, kind });
1492 Ok(id)
1493 }
1494
1495 pub fn animate_virtual_buffer<'js>(
1498 &self,
1499 buffer_id: u32,
1500 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1501 ) -> rquickjs::Result<u64> {
1502 let kind = parse_animation_kind(&kind)?;
1503 let id = self.alloc_animation_id();
1504 let _ = self
1505 .command_sender
1506 .send(PluginCommand::StartAnimationVirtualBuffer {
1507 id,
1508 buffer_id: BufferId(buffer_id as usize),
1509 kind,
1510 });
1511 Ok(id)
1512 }
1513
1514 pub fn cancel_animation(&self, id: u64) -> bool {
1517 self.command_sender
1518 .send(PluginCommand::CancelAnimation { id })
1519 .is_ok()
1520 }
1521
1522 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1526 if event_name == "lines_changed" {
1530 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1531 }
1532 self.event_handlers
1533 .borrow_mut()
1534 .entry(event_name)
1535 .or_default()
1536 .push(PluginHandler {
1537 plugin_name: self.plugin_name.clone(),
1538 handler_name,
1539 });
1540 }
1541
1542 pub fn off(&self, event_name: String, handler_name: String) {
1544 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1545 list.retain(|h| h.handler_name != handler_name);
1546 }
1547 }
1548
1549 pub fn get_env(&self, name: String) -> Option<String> {
1553 std::env::var(&name).ok()
1554 }
1555
1556 pub fn get_cwd(&self) -> String {
1558 self.state_snapshot
1559 .read()
1560 .map(|s| s.working_dir.to_string_lossy().to_string())
1561 .unwrap_or_else(|_| ".".to_string())
1562 }
1563
1564 pub fn get_authority_label(&self) -> String {
1573 self.state_snapshot
1574 .read()
1575 .map(|s| s.authority_label.clone())
1576 .unwrap_or_default()
1577 }
1578
1579 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1591 let mut result_parts: Vec<String> = Vec::new();
1592 let mut leading_slashes: u8 = 0;
1594
1595 for part in &parts.0 {
1596 let normalized = part.replace('\\', "/");
1598
1599 let is_absolute = normalized.starts_with('/')
1601 || (normalized.len() >= 2
1602 && normalized
1603 .chars()
1604 .next()
1605 .map(|c| c.is_ascii_alphabetic())
1606 .unwrap_or(false)
1607 && normalized.chars().nth(1) == Some(':'));
1608
1609 if is_absolute {
1610 result_parts.clear();
1612 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
1616 }
1617
1618 for segment in normalized.split('/') {
1620 if !segment.is_empty() && segment != "." {
1621 if segment == ".." {
1622 result_parts.pop();
1623 } else {
1624 result_parts.push(segment.to_string());
1625 }
1626 }
1627 }
1628 }
1629
1630 let joined = result_parts.join("/");
1632 let prefix = match leading_slashes {
1633 0 => "",
1634 1 => "/",
1635 _ => "//",
1636 };
1637
1638 if leading_slashes > 0 {
1639 format!("{}{}", prefix, joined)
1640 } else {
1641 joined
1642 }
1643 }
1644
1645 pub fn path_dirname(&self, path: String) -> String {
1647 Path::new(&path)
1648 .parent()
1649 .map(|p| p.to_string_lossy().to_string())
1650 .unwrap_or_default()
1651 }
1652
1653 pub fn path_basename(&self, path: String) -> String {
1655 Path::new(&path)
1656 .file_name()
1657 .map(|s| s.to_string_lossy().to_string())
1658 .unwrap_or_default()
1659 }
1660
1661 pub fn path_extname(&self, path: String) -> String {
1663 Path::new(&path)
1664 .extension()
1665 .map(|s| format!(".{}", s.to_string_lossy()))
1666 .unwrap_or_default()
1667 }
1668
1669 pub fn path_is_absolute(&self, path: String) -> bool {
1671 Path::new(&path).is_absolute()
1672 }
1673
1674 pub fn file_uri_to_path(&self, uri: String) -> String {
1678 fresh_core::file_uri::file_uri_to_path(&uri)
1679 .map(|p| p.to_string_lossy().to_string())
1680 .unwrap_or_default()
1681 }
1682
1683 pub fn path_to_file_uri(&self, path: String) -> String {
1687 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
1688 }
1689
1690 pub fn utf8_byte_length(&self, text: String) -> u32 {
1698 text.len() as u32
1699 }
1700
1701 pub fn file_exists(&self, path: String) -> bool {
1705 Path::new(&path).exists()
1706 }
1707
1708 pub fn read_file(&self, path: String) -> Option<String> {
1710 std::fs::read_to_string(&path).ok()
1711 }
1712
1713 pub fn write_file(&self, path: String, content: String) -> bool {
1715 let p = Path::new(&path);
1716 if let Some(parent) = p.parent() {
1717 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1718 return false;
1719 }
1720 }
1721 std::fs::write(p, content).is_ok()
1722 }
1723
1724 #[plugin_api(ts_return = "DirEntry[]")]
1726 pub fn read_dir<'js>(
1727 &self,
1728 ctx: rquickjs::Ctx<'js>,
1729 path: String,
1730 ) -> rquickjs::Result<Value<'js>> {
1731 use fresh_core::api::DirEntry;
1732
1733 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1734 Ok(entries) => entries
1735 .filter_map(|e| e.ok())
1736 .map(|entry| {
1737 let file_type = entry.file_type().ok();
1738 DirEntry {
1739 name: entry.file_name().to_string_lossy().to_string(),
1740 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1741 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1742 }
1743 })
1744 .collect(),
1745 Err(e) => {
1746 tracing::warn!("readDir failed for '{}': {}", path, e);
1747 Vec::new()
1748 }
1749 };
1750
1751 rquickjs_serde::to_value(ctx, &entries)
1752 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1753 }
1754
1755 pub fn create_dir(&self, path: String) -> bool {
1758 let p = Path::new(&path);
1759 if p.is_dir() {
1760 return true;
1761 }
1762 std::fs::create_dir_all(p).is_ok()
1763 }
1764
1765 pub fn remove_path(&self, path: String) -> bool {
1769 let target = match Path::new(&path).canonicalize() {
1770 Ok(p) => p,
1771 Err(_) => return false, };
1773
1774 let temp_dir = std::env::temp_dir()
1780 .canonicalize()
1781 .unwrap_or_else(|_| std::env::temp_dir());
1782 let config_dir = self
1783 .services
1784 .config_dir()
1785 .canonicalize()
1786 .unwrap_or_else(|_| self.services.config_dir());
1787
1788 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
1790 if !allowed {
1791 tracing::warn!(
1792 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
1793 target,
1794 temp_dir,
1795 config_dir
1796 );
1797 return false;
1798 }
1799
1800 if target == temp_dir || target == config_dir {
1802 tracing::warn!(
1803 "removePath refused: cannot remove root directory {:?}",
1804 target
1805 );
1806 return false;
1807 }
1808
1809 match trash::delete(&target) {
1810 Ok(()) => true,
1811 Err(e) => {
1812 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
1813 false
1814 }
1815 }
1816 }
1817
1818 pub fn rename_path(&self, from: String, to: String) -> bool {
1821 if std::fs::rename(&from, &to).is_ok() {
1823 return true;
1824 }
1825 let from_path = Path::new(&from);
1827 let copied = if from_path.is_dir() {
1828 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
1829 } else {
1830 std::fs::copy(&from, &to).is_ok()
1831 };
1832 if copied {
1833 return trash::delete(from_path).is_ok();
1834 }
1835 false
1836 }
1837
1838 pub fn copy_path(&self, from: String, to: String) -> bool {
1841 let from_path = Path::new(&from);
1842 let to_path = Path::new(&to);
1843 if from_path.is_dir() {
1844 copy_dir_recursive(from_path, to_path).is_ok()
1845 } else {
1846 if let Some(parent) = to_path.parent() {
1848 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1849 return false;
1850 }
1851 }
1852 std::fs::copy(from_path, to_path).is_ok()
1853 }
1854 }
1855
1856 pub fn get_temp_dir(&self) -> String {
1858 std::env::temp_dir().to_string_lossy().to_string()
1859 }
1860
1861 #[plugin_api(ts_return = "unknown")]
1872 pub fn parse_jsonc<'js>(
1873 &self,
1874 ctx: rquickjs::Ctx<'js>,
1875 text: String,
1876 ) -> rquickjs::Result<Value<'js>> {
1877 let value: serde_json::Value =
1878 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
1879 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
1880 })?;
1881 rquickjs_serde::to_value(ctx, &value)
1882 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1883 }
1884
1885 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1894 let config = self
1895 .state_snapshot
1896 .read()
1897 .map(|s| std::sync::Arc::clone(&s.config))
1898 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1899
1900 rquickjs_serde::to_value(ctx, &*config)
1901 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1902 }
1903
1904 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1906 let config = self
1907 .state_snapshot
1908 .read()
1909 .map(|s| std::sync::Arc::clone(&s.user_config))
1910 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1911
1912 rquickjs_serde::to_value(ctx, &*config)
1913 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1914 }
1915
1916 pub fn reload_config(&self) {
1918 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1919 }
1920
1921 pub fn set_setting<'js>(
1934 &self,
1935 _ctx: rquickjs::Ctx<'js>,
1936 path: String,
1937 value: Value<'js>,
1938 ) -> rquickjs::Result<bool> {
1939 let json: serde_json::Value = rquickjs_serde::from_value(value)
1940 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
1941 Ok(self
1942 .command_sender
1943 .send(PluginCommand::SetSetting {
1944 plugin_name: self.plugin_name.clone(),
1945 path,
1946 value: json,
1947 })
1948 .is_ok())
1949 }
1950
1951 pub fn reload_themes(&self) {
1954 let _ = self
1955 .command_sender
1956 .send(PluginCommand::ReloadThemes { apply_theme: None });
1957 }
1958
1959 pub fn reload_and_apply_theme(&self, theme_name: String) {
1961 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1962 apply_theme: Some(theme_name),
1963 });
1964 }
1965
1966 pub fn register_grammar<'js>(
1969 &self,
1970 ctx: rquickjs::Ctx<'js>,
1971 language: String,
1972 grammar_path: String,
1973 extensions: Vec<String>,
1974 ) -> rquickjs::Result<bool> {
1975 {
1977 let langs = self.registered_grammar_languages.borrow();
1978 if let Some(existing_plugin) = langs.get(&language) {
1979 if existing_plugin != &self.plugin_name {
1980 let msg = format!(
1981 "Grammar for language '{}' already registered by plugin '{}'",
1982 language, existing_plugin
1983 );
1984 tracing::warn!("registerGrammar collision: {}", msg);
1985 return Err(
1986 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1987 );
1988 }
1989 }
1990 }
1991 self.registered_grammar_languages
1992 .borrow_mut()
1993 .insert(language.clone(), self.plugin_name.clone());
1994
1995 Ok(self
1996 .command_sender
1997 .send(PluginCommand::RegisterGrammar {
1998 language,
1999 grammar_path,
2000 extensions,
2001 })
2002 .is_ok())
2003 }
2004
2005 pub fn register_language_config<'js>(
2007 &self,
2008 ctx: rquickjs::Ctx<'js>,
2009 language: String,
2010 config: LanguagePackConfig,
2011 ) -> rquickjs::Result<bool> {
2012 {
2014 let langs = self.registered_language_configs.borrow();
2015 if let Some(existing_plugin) = langs.get(&language) {
2016 if existing_plugin != &self.plugin_name {
2017 let msg = format!(
2018 "Language config for '{}' already registered by plugin '{}'",
2019 language, existing_plugin
2020 );
2021 tracing::warn!("registerLanguageConfig collision: {}", msg);
2022 return Err(
2023 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2024 );
2025 }
2026 }
2027 }
2028 self.registered_language_configs
2029 .borrow_mut()
2030 .insert(language.clone(), self.plugin_name.clone());
2031
2032 Ok(self
2033 .command_sender
2034 .send(PluginCommand::RegisterLanguageConfig { language, config })
2035 .is_ok())
2036 }
2037
2038 pub fn register_lsp_server<'js>(
2040 &self,
2041 ctx: rquickjs::Ctx<'js>,
2042 language: String,
2043 config: LspServerPackConfig,
2044 ) -> rquickjs::Result<bool> {
2045 {
2047 let langs = self.registered_lsp_servers.borrow();
2048 if let Some(existing_plugin) = langs.get(&language) {
2049 if existing_plugin != &self.plugin_name {
2050 let msg = format!(
2051 "LSP server for language '{}' already registered by plugin '{}'",
2052 language, existing_plugin
2053 );
2054 tracing::warn!("registerLspServer collision: {}", msg);
2055 return Err(
2056 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2057 );
2058 }
2059 }
2060 }
2061 self.registered_lsp_servers
2062 .borrow_mut()
2063 .insert(language.clone(), self.plugin_name.clone());
2064
2065 Ok(self
2066 .command_sender
2067 .send(PluginCommand::RegisterLspServer { language, config })
2068 .is_ok())
2069 }
2070
2071 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
2075 #[qjs(rename = "_reloadGrammarsStart")]
2076 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2077 let id = self.alloc_request_id();
2078 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
2079 callback_id: fresh_core::api::JsCallbackId::new(id),
2080 });
2081 id
2082 }
2083
2084 pub fn get_plugin_dir(&self) -> String {
2087 self.services
2088 .plugins_dir()
2089 .join("packages")
2090 .join(&self.plugin_name)
2091 .to_string_lossy()
2092 .to_string()
2093 }
2094
2095 pub fn get_config_dir(&self) -> String {
2097 self.services.config_dir().to_string_lossy().to_string()
2098 }
2099
2100 pub fn get_data_dir(&self) -> String {
2104 self.services.data_dir().to_string_lossy().to_string()
2105 }
2106
2107 pub fn get_themes_dir(&self) -> String {
2109 self.services
2110 .config_dir()
2111 .join("themes")
2112 .to_string_lossy()
2113 .to_string()
2114 }
2115
2116 pub fn apply_theme(&self, theme_name: String) -> bool {
2118 self.command_sender
2119 .send(PluginCommand::ApplyTheme { theme_name })
2120 .is_ok()
2121 }
2122
2123 pub fn override_theme_colors<'js>(
2132 &self,
2133 _ctx: rquickjs::Ctx<'js>,
2134 overrides: Value<'js>,
2135 ) -> rquickjs::Result<bool> {
2136 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
2142 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
2143 let Some(obj) = json.as_object() else {
2144 return Err(rquickjs::Error::new_from_js_message(
2145 "type",
2146 "",
2147 "overrideThemeColors expects an object of \"key\": [r, g, b]",
2148 ));
2149 };
2150 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
2151 n.as_i64()
2152 .or_else(|| n.as_f64().map(|f| f as i64))
2153 .map(|v| v.clamp(0, 255) as u8)
2154 };
2155 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
2156 std::collections::HashMap::with_capacity(obj.len());
2157 for (key, value) in obj {
2158 let Some(arr) = value.as_array() else {
2159 continue;
2160 };
2161 if arr.len() != 3 {
2162 continue;
2163 }
2164 let Some(r) = to_u8(&arr[0]) else { continue };
2165 let Some(g) = to_u8(&arr[1]) else { continue };
2166 let Some(b) = to_u8(&arr[2]) else { continue };
2167 clamped.insert(key.clone(), [r, g, b]);
2168 }
2169 Ok(self
2170 .command_sender
2171 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
2172 .is_ok())
2173 }
2174
2175 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2177 let schema = self.services.get_theme_schema();
2178 rquickjs_serde::to_value(ctx, &schema)
2179 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2180 }
2181
2182 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2184 let themes = self.services.get_builtin_themes();
2185 rquickjs_serde::to_value(ctx, &themes)
2186 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2187 }
2188
2189 #[qjs(rename = "_deleteThemeSync")]
2191 pub fn delete_theme_sync(&self, name: String) -> bool {
2192 let themes_dir = self.services.config_dir().join("themes");
2194 let theme_path = themes_dir.join(format!("{}.json", name));
2195
2196 if let Ok(canonical) = theme_path.canonicalize() {
2198 if let Ok(themes_canonical) = themes_dir.canonicalize() {
2199 if canonical.starts_with(&themes_canonical) {
2200 return std::fs::remove_file(&canonical).is_ok();
2201 }
2202 }
2203 }
2204 false
2205 }
2206
2207 pub fn delete_theme(&self, name: String) -> bool {
2209 self.delete_theme_sync(name)
2210 }
2211
2212 pub fn get_theme_data<'js>(
2214 &self,
2215 ctx: rquickjs::Ctx<'js>,
2216 name: String,
2217 ) -> rquickjs::Result<Value<'js>> {
2218 match self.services.get_theme_data(&name) {
2219 Some(data) => rquickjs_serde::to_value(ctx, &data)
2220 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
2221 None => Ok(Value::new_null(ctx)),
2222 }
2223 }
2224
2225 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
2227 self.services
2228 .save_theme_file(&name, &content)
2229 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
2230 }
2231
2232 pub fn theme_file_exists(&self, name: String) -> bool {
2234 self.services.theme_file_exists(&name)
2235 }
2236
2237 pub fn file_stat<'js>(
2241 &self,
2242 ctx: rquickjs::Ctx<'js>,
2243 path: String,
2244 ) -> rquickjs::Result<Value<'js>> {
2245 let metadata = std::fs::metadata(&path).ok();
2246 let stat = metadata.map(|m| {
2247 serde_json::json!({
2248 "isFile": m.is_file(),
2249 "isDir": m.is_dir(),
2250 "size": m.len(),
2251 "readonly": m.permissions().readonly(),
2252 })
2253 });
2254 rquickjs_serde::to_value(ctx, &stat)
2255 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2256 }
2257
2258 pub fn is_process_running(&self, _process_id: u64) -> bool {
2262 false
2265 }
2266
2267 pub fn kill_process(&self, process_id: u64) -> bool {
2269 self.command_sender
2270 .send(PluginCommand::KillBackgroundProcess { process_id })
2271 .is_ok()
2272 }
2273
2274 pub fn plugin_translate<'js>(
2278 &self,
2279 _ctx: rquickjs::Ctx<'js>,
2280 plugin_name: String,
2281 key: String,
2282 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
2283 ) -> String {
2284 let args_map: HashMap<String, String> = args
2285 .0
2286 .map(|obj| {
2287 let mut map = HashMap::new();
2288 for (k, v) in obj.props::<String, String>().flatten() {
2289 map.insert(k, v);
2290 }
2291 map
2292 })
2293 .unwrap_or_default();
2294
2295 self.services.translate(&plugin_name, &key, &args_map)
2296 }
2297
2298 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
2305 #[qjs(rename = "_createCompositeBufferStart")]
2306 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
2307 let id = self.alloc_request_id();
2308
2309 if let Ok(mut owners) = self.async_resource_owners.lock() {
2311 owners.insert(id, self.plugin_name.clone());
2312 }
2313 let _ = self
2314 .command_sender
2315 .send(PluginCommand::CreateCompositeBuffer {
2316 name: opts.name,
2317 mode: opts.mode,
2318 layout: opts.layout,
2319 sources: opts.sources,
2320 hunks: opts.hunks,
2321 initial_focus_hunk: opts.initial_focus_hunk,
2322 request_id: Some(id),
2323 });
2324
2325 id
2326 }
2327
2328 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
2332 self.command_sender
2333 .send(PluginCommand::UpdateCompositeAlignment {
2334 buffer_id: BufferId(buffer_id as usize),
2335 hunks,
2336 })
2337 .is_ok()
2338 }
2339
2340 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
2342 self.command_sender
2343 .send(PluginCommand::CloseCompositeBuffer {
2344 buffer_id: BufferId(buffer_id as usize),
2345 })
2346 .is_ok()
2347 }
2348
2349 pub fn flush_layout(&self) -> bool {
2353 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
2354 }
2355
2356 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
2358 self.command_sender
2359 .send(PluginCommand::CompositeNextHunk {
2360 buffer_id: BufferId(buffer_id as usize),
2361 })
2362 .is_ok()
2363 }
2364
2365 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
2367 self.command_sender
2368 .send(PluginCommand::CompositePrevHunk {
2369 buffer_id: BufferId(buffer_id as usize),
2370 })
2371 .is_ok()
2372 }
2373
2374 #[plugin_api(
2378 async_promise,
2379 js_name = "getHighlights",
2380 ts_return = "TsHighlightSpan[]"
2381 )]
2382 #[qjs(rename = "_getHighlightsStart")]
2383 pub fn get_highlights_start<'js>(
2384 &self,
2385 _ctx: rquickjs::Ctx<'js>,
2386 buffer_id: u32,
2387 start: u32,
2388 end: u32,
2389 ) -> rquickjs::Result<u64> {
2390 let id = self.alloc_request_id();
2391
2392 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2393 buffer_id: BufferId(buffer_id as usize),
2394 range: (start as usize)..(end as usize),
2395 request_id: id,
2396 });
2397
2398 Ok(id)
2399 }
2400
2401 pub fn add_overlay<'js>(
2423 &self,
2424 _ctx: rquickjs::Ctx<'js>,
2425 buffer_id: u32,
2426 namespace: String,
2427 start: u32,
2428 end: u32,
2429 options: rquickjs::Object<'js>,
2430 ) -> rquickjs::Result<bool> {
2431 use fresh_core::api::OverlayColorSpec;
2432
2433 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2435 if let Ok(theme_key) = obj.get::<_, String>(key) {
2437 if !theme_key.is_empty() {
2438 return Some(OverlayColorSpec::ThemeKey(theme_key));
2439 }
2440 }
2441 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2443 if arr.len() >= 3 {
2444 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2445 }
2446 }
2447 None
2448 }
2449
2450 let fg = parse_color_spec("fg", &options);
2451 let bg = parse_color_spec("bg", &options);
2452 let underline: bool = options.get("underline").unwrap_or(false);
2453 let bold: bool = options.get("bold").unwrap_or(false);
2454 let italic: bool = options.get("italic").unwrap_or(false);
2455 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2456 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2457 let url: Option<String> = options.get("url").ok();
2458
2459 let options = OverlayOptions {
2460 fg,
2461 bg,
2462 underline,
2463 bold,
2464 italic,
2465 strikethrough,
2466 extend_to_line_end,
2467 url,
2468 };
2469
2470 self.plugin_tracked_state
2472 .borrow_mut()
2473 .entry(self.plugin_name.clone())
2474 .or_default()
2475 .overlay_namespaces
2476 .push((BufferId(buffer_id as usize), namespace.clone()));
2477
2478 let _ = self.command_sender.send(PluginCommand::AddOverlay {
2479 buffer_id: BufferId(buffer_id as usize),
2480 namespace: Some(OverlayNamespace::from_string(namespace)),
2481 range: (start as usize)..(end as usize),
2482 options,
2483 });
2484
2485 Ok(true)
2486 }
2487
2488 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2490 self.command_sender
2491 .send(PluginCommand::ClearNamespace {
2492 buffer_id: BufferId(buffer_id as usize),
2493 namespace: OverlayNamespace::from_string(namespace),
2494 })
2495 .is_ok()
2496 }
2497
2498 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2500 self.command_sender
2501 .send(PluginCommand::ClearAllOverlays {
2502 buffer_id: BufferId(buffer_id as usize),
2503 })
2504 .is_ok()
2505 }
2506
2507 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2509 self.command_sender
2510 .send(PluginCommand::ClearOverlaysInRange {
2511 buffer_id: BufferId(buffer_id as usize),
2512 start: start as usize,
2513 end: end as usize,
2514 })
2515 .is_ok()
2516 }
2517
2518 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2520 use fresh_core::overlay::OverlayHandle;
2521 self.command_sender
2522 .send(PluginCommand::RemoveOverlay {
2523 buffer_id: BufferId(buffer_id as usize),
2524 handle: OverlayHandle(handle),
2525 })
2526 .is_ok()
2527 }
2528
2529 pub fn add_conceal(
2533 &self,
2534 buffer_id: u32,
2535 namespace: String,
2536 start: u32,
2537 end: u32,
2538 replacement: Option<String>,
2539 ) -> bool {
2540 self.plugin_tracked_state
2542 .borrow_mut()
2543 .entry(self.plugin_name.clone())
2544 .or_default()
2545 .overlay_namespaces
2546 .push((BufferId(buffer_id as usize), namespace.clone()));
2547
2548 self.command_sender
2549 .send(PluginCommand::AddConceal {
2550 buffer_id: BufferId(buffer_id as usize),
2551 namespace: OverlayNamespace::from_string(namespace),
2552 start: start as usize,
2553 end: end as usize,
2554 replacement,
2555 })
2556 .is_ok()
2557 }
2558
2559 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2561 self.command_sender
2562 .send(PluginCommand::ClearConcealNamespace {
2563 buffer_id: BufferId(buffer_id as usize),
2564 namespace: OverlayNamespace::from_string(namespace),
2565 })
2566 .is_ok()
2567 }
2568
2569 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2571 self.command_sender
2572 .send(PluginCommand::ClearConcealsInRange {
2573 buffer_id: BufferId(buffer_id as usize),
2574 start: start as usize,
2575 end: end as usize,
2576 })
2577 .is_ok()
2578 }
2579
2580 pub fn add_fold(
2587 &self,
2588 buffer_id: u32,
2589 start: u32,
2590 end: u32,
2591 placeholder: rquickjs::function::Opt<String>,
2592 ) -> bool {
2593 self.command_sender
2594 .send(PluginCommand::AddFold {
2595 buffer_id: BufferId(buffer_id as usize),
2596 start: start as usize,
2597 end: end as usize,
2598 placeholder: placeholder.0,
2599 })
2600 .is_ok()
2601 }
2602
2603 pub fn clear_folds(&self, buffer_id: u32) -> bool {
2605 self.command_sender
2606 .send(PluginCommand::ClearFolds {
2607 buffer_id: BufferId(buffer_id as usize),
2608 })
2609 .is_ok()
2610 }
2611
2612 pub fn add_soft_break(
2616 &self,
2617 buffer_id: u32,
2618 namespace: String,
2619 position: u32,
2620 indent: u32,
2621 ) -> bool {
2622 self.plugin_tracked_state
2624 .borrow_mut()
2625 .entry(self.plugin_name.clone())
2626 .or_default()
2627 .overlay_namespaces
2628 .push((BufferId(buffer_id as usize), namespace.clone()));
2629
2630 self.command_sender
2631 .send(PluginCommand::AddSoftBreak {
2632 buffer_id: BufferId(buffer_id as usize),
2633 namespace: OverlayNamespace::from_string(namespace),
2634 position: position as usize,
2635 indent: indent as u16,
2636 })
2637 .is_ok()
2638 }
2639
2640 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2642 self.command_sender
2643 .send(PluginCommand::ClearSoftBreakNamespace {
2644 buffer_id: BufferId(buffer_id as usize),
2645 namespace: OverlayNamespace::from_string(namespace),
2646 })
2647 .is_ok()
2648 }
2649
2650 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2652 self.command_sender
2653 .send(PluginCommand::ClearSoftBreaksInRange {
2654 buffer_id: BufferId(buffer_id as usize),
2655 start: start as usize,
2656 end: end as usize,
2657 })
2658 .is_ok()
2659 }
2660
2661 #[allow(clippy::too_many_arguments)]
2671 pub fn submit_view_transform<'js>(
2672 &self,
2673 _ctx: rquickjs::Ctx<'js>,
2674 buffer_id: u32,
2675 split_id: Option<u32>,
2676 start: u32,
2677 end: u32,
2678 tokens: Vec<rquickjs::Object<'js>>,
2679 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2680 ) -> rquickjs::Result<bool> {
2681 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2682
2683 let tokens: Vec<ViewTokenWire> = tokens
2684 .into_iter()
2685 .enumerate()
2686 .map(|(idx, obj)| {
2687 parse_view_token(&obj, idx)
2689 })
2690 .collect::<rquickjs::Result<Vec<_>>>()?;
2691
2692 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2694 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2695 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2696 Some(LayoutHints {
2697 compose_width,
2698 column_guides,
2699 })
2700 } else {
2701 None
2702 };
2703
2704 let payload = ViewTransformPayload {
2705 range: (start as usize)..(end as usize),
2706 tokens,
2707 layout_hints: parsed_layout_hints,
2708 };
2709
2710 Ok(self
2711 .command_sender
2712 .send(PluginCommand::SubmitViewTransform {
2713 buffer_id: BufferId(buffer_id as usize),
2714 split_id: split_id.map(|id| SplitId(id as usize)),
2715 payload,
2716 })
2717 .is_ok())
2718 }
2719
2720 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2722 self.command_sender
2723 .send(PluginCommand::ClearViewTransform {
2724 buffer_id: BufferId(buffer_id as usize),
2725 split_id: split_id.map(|id| SplitId(id as usize)),
2726 })
2727 .is_ok()
2728 }
2729
2730 pub fn set_layout_hints<'js>(
2733 &self,
2734 buffer_id: u32,
2735 split_id: Option<u32>,
2736 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2737 ) -> rquickjs::Result<bool> {
2738 use fresh_core::api::LayoutHints;
2739
2740 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2741 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2742 let parsed_hints = LayoutHints {
2743 compose_width,
2744 column_guides,
2745 };
2746
2747 Ok(self
2748 .command_sender
2749 .send(PluginCommand::SetLayoutHints {
2750 buffer_id: BufferId(buffer_id as usize),
2751 split_id: split_id.map(|id| SplitId(id as usize)),
2752 range: 0..0,
2753 hints: parsed_hints,
2754 })
2755 .is_ok())
2756 }
2757
2758 pub fn set_file_explorer_decorations<'js>(
2762 &self,
2763 _ctx: rquickjs::Ctx<'js>,
2764 namespace: String,
2765 decorations: Vec<rquickjs::Object<'js>>,
2766 ) -> rquickjs::Result<bool> {
2767 use fresh_core::file_explorer::FileExplorerDecoration;
2768
2769 let decorations: Vec<FileExplorerDecoration> = decorations
2770 .into_iter()
2771 .map(|obj| {
2772 let path: String = obj.get("path")?;
2773 let symbol: String = obj.get("symbol")?;
2774 let priority: i32 = obj.get("priority").unwrap_or(0);
2775
2776 let color_val: rquickjs::Value = obj.get("color")?;
2778 let color = if color_val.is_string() {
2779 let key: String = color_val.get()?;
2780 fresh_core::api::OverlayColorSpec::ThemeKey(key)
2781 } else if color_val.is_array() {
2782 let arr: Vec<u8> = color_val.get()?;
2783 if arr.len() < 3 {
2784 return Err(rquickjs::Error::FromJs {
2785 from: "array",
2786 to: "color",
2787 message: Some(format!(
2788 "color array must have at least 3 elements, got {}",
2789 arr.len()
2790 )),
2791 });
2792 }
2793 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
2794 } else {
2795 return Err(rquickjs::Error::FromJs {
2796 from: "value",
2797 to: "color",
2798 message: Some("color must be an RGB array or theme key string".to_string()),
2799 });
2800 };
2801
2802 Ok(FileExplorerDecoration {
2803 path: std::path::PathBuf::from(path),
2804 symbol,
2805 color,
2806 priority,
2807 })
2808 })
2809 .collect::<rquickjs::Result<Vec<_>>>()?;
2810
2811 self.plugin_tracked_state
2813 .borrow_mut()
2814 .entry(self.plugin_name.clone())
2815 .or_default()
2816 .file_explorer_namespaces
2817 .push(namespace.clone());
2818
2819 Ok(self
2820 .command_sender
2821 .send(PluginCommand::SetFileExplorerDecorations {
2822 namespace,
2823 decorations,
2824 })
2825 .is_ok())
2826 }
2827
2828 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2830 self.command_sender
2831 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2832 .is_ok()
2833 }
2834
2835 #[allow(clippy::too_many_arguments)]
2839 pub fn add_virtual_text(
2840 &self,
2841 buffer_id: u32,
2842 virtual_text_id: String,
2843 position: u32,
2844 text: String,
2845 r: u8,
2846 g: u8,
2847 b: u8,
2848 before: bool,
2849 use_bg: bool,
2850 ) -> bool {
2851 self.plugin_tracked_state
2853 .borrow_mut()
2854 .entry(self.plugin_name.clone())
2855 .or_default()
2856 .virtual_text_ids
2857 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2858
2859 self.command_sender
2860 .send(PluginCommand::AddVirtualText {
2861 buffer_id: BufferId(buffer_id as usize),
2862 virtual_text_id,
2863 position: position as usize,
2864 text,
2865 color: (r, g, b),
2866 use_bg,
2867 before,
2868 })
2869 .is_ok()
2870 }
2871
2872 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2874 self.command_sender
2875 .send(PluginCommand::RemoveVirtualText {
2876 buffer_id: BufferId(buffer_id as usize),
2877 virtual_text_id,
2878 })
2879 .is_ok()
2880 }
2881
2882 #[allow(clippy::too_many_arguments)]
2888 pub fn add_virtual_text_styled<'js>(
2889 &self,
2890 _ctx: rquickjs::Ctx<'js>,
2891 buffer_id: u32,
2892 virtual_text_id: String,
2893 position: u32,
2894 text: String,
2895 options: rquickjs::Object<'js>,
2896 before: bool,
2897 ) -> rquickjs::Result<bool> {
2898 use fresh_core::api::OverlayColorSpec;
2899
2900 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2903 if let Ok(theme_key) = obj.get::<_, String>(key) {
2904 if !theme_key.is_empty() {
2905 return Some(OverlayColorSpec::ThemeKey(theme_key));
2906 }
2907 }
2908 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2909 if arr.len() >= 3 {
2910 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2911 }
2912 }
2913 None
2914 }
2915
2916 let fg = parse_color_spec("fg", &options);
2917 let bg = parse_color_spec("bg", &options);
2918 let bold: bool = options.get("bold").unwrap_or(false);
2919 let italic: bool = options.get("italic").unwrap_or(false);
2920
2921 self.plugin_tracked_state
2923 .borrow_mut()
2924 .entry(self.plugin_name.clone())
2925 .or_default()
2926 .virtual_text_ids
2927 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2928
2929 let _ = self
2930 .command_sender
2931 .send(PluginCommand::AddVirtualTextStyled {
2932 buffer_id: BufferId(buffer_id as usize),
2933 virtual_text_id,
2934 position: position as usize,
2935 text,
2936 fg,
2937 bg,
2938 bold,
2939 italic,
2940 before,
2941 });
2942 Ok(true)
2943 }
2944
2945 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2947 self.command_sender
2948 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2949 buffer_id: BufferId(buffer_id as usize),
2950 prefix,
2951 })
2952 .is_ok()
2953 }
2954
2955 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2957 self.command_sender
2958 .send(PluginCommand::ClearVirtualTexts {
2959 buffer_id: BufferId(buffer_id as usize),
2960 })
2961 .is_ok()
2962 }
2963
2964 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2966 self.command_sender
2967 .send(PluginCommand::ClearVirtualTextNamespace {
2968 buffer_id: BufferId(buffer_id as usize),
2969 namespace,
2970 })
2971 .is_ok()
2972 }
2973
2974 #[allow(clippy::too_many_arguments)]
2982 pub fn add_virtual_line<'js>(
2983 &self,
2984 _ctx: rquickjs::Ctx<'js>,
2985 buffer_id: u32,
2986 position: u32,
2987 text: String,
2988 options: rquickjs::Object<'js>,
2989 above: bool,
2990 namespace: String,
2991 priority: i32,
2992 ) -> rquickjs::Result<bool> {
2993 use fresh_core::api::OverlayColorSpec;
2994
2995 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2998 if let Ok(theme_key) = obj.get::<_, String>(key) {
2999 if !theme_key.is_empty() {
3000 return Some(OverlayColorSpec::ThemeKey(theme_key));
3001 }
3002 }
3003 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3004 if arr.len() >= 3 {
3005 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3006 }
3007 }
3008 None
3009 }
3010
3011 let fg_color = parse_color_spec("fg", &options);
3012 let bg_color = parse_color_spec("bg", &options);
3013
3014 self.plugin_tracked_state
3016 .borrow_mut()
3017 .entry(self.plugin_name.clone())
3018 .or_default()
3019 .virtual_line_namespaces
3020 .push((BufferId(buffer_id as usize), namespace.clone()));
3021
3022 Ok(self
3023 .command_sender
3024 .send(PluginCommand::AddVirtualLine {
3025 buffer_id: BufferId(buffer_id as usize),
3026 position: position as usize,
3027 text,
3028 fg_color,
3029 bg_color,
3030 above,
3031 namespace,
3032 priority,
3033 })
3034 .is_ok())
3035 }
3036
3037 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
3042 #[qjs(rename = "_promptStart")]
3043 pub fn prompt_start(
3044 &self,
3045 _ctx: rquickjs::Ctx<'_>,
3046 label: String,
3047 initial_value: String,
3048 ) -> u64 {
3049 let id = self.alloc_request_id();
3050
3051 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
3052 label,
3053 initial_value,
3054 callback_id: JsCallbackId::new(id),
3055 });
3056
3057 id
3058 }
3059
3060 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
3062 self.command_sender
3063 .send(PluginCommand::StartPrompt { label, prompt_type })
3064 .is_ok()
3065 }
3066
3067 pub fn begin_key_capture(&self) -> bool {
3077 self.command_sender
3078 .send(PluginCommand::SetKeyCaptureActive { active: true })
3079 .is_ok()
3080 }
3081
3082 pub fn end_key_capture(&self) -> bool {
3086 self.command_sender
3087 .send(PluginCommand::SetKeyCaptureActive { active: false })
3088 .is_ok()
3089 }
3090
3091 #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
3103 #[qjs(rename = "_getNextKeyStart")]
3104 pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3105 let id = self.alloc_request_id();
3106 let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
3107 callback_id: JsCallbackId::new(id),
3108 });
3109 id
3110 }
3111
3112 pub fn start_prompt_with_initial(
3114 &self,
3115 label: String,
3116 prompt_type: String,
3117 initial_value: String,
3118 ) -> bool {
3119 self.command_sender
3120 .send(PluginCommand::StartPromptWithInitial {
3121 label,
3122 prompt_type,
3123 initial_value,
3124 })
3125 .is_ok()
3126 }
3127
3128 pub fn set_prompt_suggestions(
3132 &self,
3133 suggestions: Vec<fresh_core::command::Suggestion>,
3134 ) -> bool {
3135 self.command_sender
3136 .send(PluginCommand::SetPromptSuggestions { suggestions })
3137 .is_ok()
3138 }
3139
3140 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
3141 self.command_sender
3142 .send(PluginCommand::SetPromptInputSync { sync })
3143 .is_ok()
3144 }
3145
3146 pub fn define_mode(
3150 &self,
3151 name: String,
3152 bindings_arr: Vec<Vec<String>>,
3153 read_only: rquickjs::function::Opt<bool>,
3154 allow_text_input: rquickjs::function::Opt<bool>,
3155 inherit_normal_bindings: rquickjs::function::Opt<bool>,
3156 ) -> bool {
3157 let bindings: Vec<(String, String)> = bindings_arr
3158 .into_iter()
3159 .filter_map(|arr| {
3160 if arr.len() >= 2 {
3161 Some((arr[0].clone(), arr[1].clone()))
3162 } else {
3163 None
3164 }
3165 })
3166 .collect();
3167
3168 {
3171 let mut registered = self.registered_actions.borrow_mut();
3172 for (_, cmd_name) in &bindings {
3173 registered.insert(
3174 cmd_name.clone(),
3175 PluginHandler {
3176 plugin_name: self.plugin_name.clone(),
3177 handler_name: cmd_name.clone(),
3178 },
3179 );
3180 }
3181 }
3182
3183 let allow_text = allow_text_input.0.unwrap_or(false);
3186 if allow_text {
3187 let mut registered = self.registered_actions.borrow_mut();
3188 registered.insert(
3189 "mode_text_input".to_string(),
3190 PluginHandler {
3191 plugin_name: self.plugin_name.clone(),
3192 handler_name: "mode_text_input".to_string(),
3193 },
3194 );
3195 }
3196
3197 self.command_sender
3198 .send(PluginCommand::DefineMode {
3199 name,
3200 bindings,
3201 read_only: read_only.0.unwrap_or(false),
3202 allow_text_input: allow_text,
3203 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
3204 plugin_name: Some(self.plugin_name.clone()),
3205 })
3206 .is_ok()
3207 }
3208
3209 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
3211 self.command_sender
3212 .send(PluginCommand::SetEditorMode { mode })
3213 .is_ok()
3214 }
3215
3216 pub fn get_editor_mode(&self) -> Option<String> {
3218 self.state_snapshot
3219 .read()
3220 .ok()
3221 .and_then(|s| s.editor_mode.clone())
3222 }
3223
3224 pub fn close_split(&self, split_id: u32) -> bool {
3228 self.command_sender
3229 .send(PluginCommand::CloseSplit {
3230 split_id: SplitId(split_id as usize),
3231 })
3232 .is_ok()
3233 }
3234
3235 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
3237 self.command_sender
3238 .send(PluginCommand::SetSplitBuffer {
3239 split_id: SplitId(split_id as usize),
3240 buffer_id: BufferId(buffer_id as usize),
3241 })
3242 .is_ok()
3243 }
3244
3245 pub fn focus_split(&self, split_id: u32) -> bool {
3247 self.command_sender
3248 .send(PluginCommand::FocusSplit {
3249 split_id: SplitId(split_id as usize),
3250 })
3251 .is_ok()
3252 }
3253
3254 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
3256 self.command_sender
3257 .send(PluginCommand::SetSplitScroll {
3258 split_id: SplitId(split_id as usize),
3259 top_byte: top_byte as usize,
3260 })
3261 .is_ok()
3262 }
3263
3264 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
3266 self.command_sender
3267 .send(PluginCommand::SetSplitRatio {
3268 split_id: SplitId(split_id as usize),
3269 ratio,
3270 })
3271 .is_ok()
3272 }
3273
3274 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
3276 self.command_sender
3277 .send(PluginCommand::SetSplitLabel {
3278 split_id: SplitId(split_id as usize),
3279 label,
3280 })
3281 .is_ok()
3282 }
3283
3284 pub fn clear_split_label(&self, split_id: u32) -> bool {
3286 self.command_sender
3287 .send(PluginCommand::ClearSplitLabel {
3288 split_id: SplitId(split_id as usize),
3289 })
3290 .is_ok()
3291 }
3292
3293 #[plugin_api(
3295 async_promise,
3296 js_name = "getSplitByLabel",
3297 ts_return = "number | null"
3298 )]
3299 #[qjs(rename = "_getSplitByLabelStart")]
3300 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
3301 let id = self.alloc_request_id();
3302 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
3303 label,
3304 request_id: id,
3305 });
3306 id
3307 }
3308
3309 pub fn distribute_splits_evenly(&self) -> bool {
3311 self.command_sender
3313 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
3314 .is_ok()
3315 }
3316
3317 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
3319 self.command_sender
3320 .send(PluginCommand::SetBufferCursor {
3321 buffer_id: BufferId(buffer_id as usize),
3322 position: position as usize,
3323 })
3324 .is_ok()
3325 }
3326
3327 #[qjs(rename = "setBufferShowCursors")]
3334 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
3335 self.command_sender
3336 .send(PluginCommand::SetBufferShowCursors {
3337 buffer_id: BufferId(buffer_id as usize),
3338 show,
3339 })
3340 .is_ok()
3341 }
3342
3343 #[allow(clippy::too_many_arguments)]
3347 pub fn set_line_indicator(
3348 &self,
3349 buffer_id: u32,
3350 line: u32,
3351 namespace: String,
3352 symbol: String,
3353 r: u8,
3354 g: u8,
3355 b: u8,
3356 priority: i32,
3357 ) -> bool {
3358 self.plugin_tracked_state
3360 .borrow_mut()
3361 .entry(self.plugin_name.clone())
3362 .or_default()
3363 .line_indicator_namespaces
3364 .push((BufferId(buffer_id as usize), namespace.clone()));
3365
3366 self.command_sender
3367 .send(PluginCommand::SetLineIndicator {
3368 buffer_id: BufferId(buffer_id as usize),
3369 line: line as usize,
3370 namespace,
3371 symbol,
3372 color: (r, g, b),
3373 priority,
3374 })
3375 .is_ok()
3376 }
3377
3378 #[allow(clippy::too_many_arguments)]
3380 pub fn set_line_indicators(
3381 &self,
3382 buffer_id: u32,
3383 lines: Vec<u32>,
3384 namespace: String,
3385 symbol: String,
3386 r: u8,
3387 g: u8,
3388 b: u8,
3389 priority: i32,
3390 ) -> bool {
3391 self.plugin_tracked_state
3393 .borrow_mut()
3394 .entry(self.plugin_name.clone())
3395 .or_default()
3396 .line_indicator_namespaces
3397 .push((BufferId(buffer_id as usize), namespace.clone()));
3398
3399 self.command_sender
3400 .send(PluginCommand::SetLineIndicators {
3401 buffer_id: BufferId(buffer_id as usize),
3402 lines: lines.into_iter().map(|l| l as usize).collect(),
3403 namespace,
3404 symbol,
3405 color: (r, g, b),
3406 priority,
3407 })
3408 .is_ok()
3409 }
3410
3411 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
3413 self.command_sender
3414 .send(PluginCommand::ClearLineIndicators {
3415 buffer_id: BufferId(buffer_id as usize),
3416 namespace,
3417 })
3418 .is_ok()
3419 }
3420
3421 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
3423 self.command_sender
3424 .send(PluginCommand::SetLineNumbers {
3425 buffer_id: BufferId(buffer_id as usize),
3426 enabled,
3427 })
3428 .is_ok()
3429 }
3430
3431 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
3433 self.command_sender
3434 .send(PluginCommand::SetViewMode {
3435 buffer_id: BufferId(buffer_id as usize),
3436 mode,
3437 })
3438 .is_ok()
3439 }
3440
3441 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
3443 self.command_sender
3444 .send(PluginCommand::SetLineWrap {
3445 buffer_id: BufferId(buffer_id as usize),
3446 split_id: split_id.map(|s| SplitId(s as usize)),
3447 enabled,
3448 })
3449 .is_ok()
3450 }
3451
3452 pub fn set_view_state<'js>(
3456 &self,
3457 ctx: rquickjs::Ctx<'js>,
3458 buffer_id: u32,
3459 key: String,
3460 value: Value<'js>,
3461 ) -> bool {
3462 let bid = BufferId(buffer_id as usize);
3463
3464 let json_value = if value.is_undefined() || value.is_null() {
3466 None
3467 } else {
3468 Some(js_to_json(&ctx, value))
3469 };
3470
3471 if let Ok(mut snapshot) = self.state_snapshot.write() {
3473 if let Some(ref json_val) = json_value {
3474 snapshot
3475 .plugin_view_states
3476 .entry(bid)
3477 .or_default()
3478 .insert(key.clone(), json_val.clone());
3479 } else {
3480 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
3482 map.remove(&key);
3483 if map.is_empty() {
3484 snapshot.plugin_view_states.remove(&bid);
3485 }
3486 }
3487 }
3488 }
3489
3490 self.command_sender
3492 .send(PluginCommand::SetViewState {
3493 buffer_id: bid,
3494 key,
3495 value: json_value,
3496 })
3497 .is_ok()
3498 }
3499
3500 pub fn get_view_state<'js>(
3502 &self,
3503 ctx: rquickjs::Ctx<'js>,
3504 buffer_id: u32,
3505 key: String,
3506 ) -> rquickjs::Result<Value<'js>> {
3507 let bid = BufferId(buffer_id as usize);
3508 if let Ok(snapshot) = self.state_snapshot.read() {
3509 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
3510 if let Some(json_val) = map.get(&key) {
3511 return json_to_js_value(&ctx, json_val);
3512 }
3513 }
3514 }
3515 Ok(Value::new_undefined(ctx.clone()))
3516 }
3517
3518 pub fn set_global_state<'js>(
3524 &self,
3525 ctx: rquickjs::Ctx<'js>,
3526 key: String,
3527 value: Value<'js>,
3528 ) -> bool {
3529 let json_value = if value.is_undefined() || value.is_null() {
3531 None
3532 } else {
3533 Some(js_to_json(&ctx, value))
3534 };
3535
3536 if let Ok(mut snapshot) = self.state_snapshot.write() {
3538 if let Some(ref json_val) = json_value {
3539 snapshot
3540 .plugin_global_states
3541 .entry(self.plugin_name.clone())
3542 .or_default()
3543 .insert(key.clone(), json_val.clone());
3544 } else {
3545 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3547 map.remove(&key);
3548 if map.is_empty() {
3549 snapshot.plugin_global_states.remove(&self.plugin_name);
3550 }
3551 }
3552 }
3553 }
3554
3555 self.command_sender
3557 .send(PluginCommand::SetGlobalState {
3558 plugin_name: self.plugin_name.clone(),
3559 key,
3560 value: json_value,
3561 })
3562 .is_ok()
3563 }
3564
3565 pub fn get_global_state<'js>(
3569 &self,
3570 ctx: rquickjs::Ctx<'js>,
3571 key: String,
3572 ) -> rquickjs::Result<Value<'js>> {
3573 if let Ok(snapshot) = self.state_snapshot.read() {
3574 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3575 if let Some(json_val) = map.get(&key) {
3576 return json_to_js_value(&ctx, json_val);
3577 }
3578 }
3579 }
3580 Ok(Value::new_undefined(ctx.clone()))
3581 }
3582
3583 pub fn create_scroll_sync_group(
3587 &self,
3588 group_id: u32,
3589 left_split: u32,
3590 right_split: u32,
3591 ) -> bool {
3592 self.plugin_tracked_state
3594 .borrow_mut()
3595 .entry(self.plugin_name.clone())
3596 .or_default()
3597 .scroll_sync_group_ids
3598 .push(group_id);
3599 self.command_sender
3600 .send(PluginCommand::CreateScrollSyncGroup {
3601 group_id,
3602 left_split: SplitId(left_split as usize),
3603 right_split: SplitId(right_split as usize),
3604 })
3605 .is_ok()
3606 }
3607
3608 pub fn set_scroll_sync_anchors<'js>(
3610 &self,
3611 _ctx: rquickjs::Ctx<'js>,
3612 group_id: u32,
3613 anchors: Vec<Vec<u32>>,
3614 ) -> bool {
3615 let anchors: Vec<(usize, usize)> = anchors
3616 .into_iter()
3617 .filter_map(|pair| {
3618 if pair.len() >= 2 {
3619 Some((pair[0] as usize, pair[1] as usize))
3620 } else {
3621 None
3622 }
3623 })
3624 .collect();
3625 self.command_sender
3626 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3627 .is_ok()
3628 }
3629
3630 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3632 self.command_sender
3633 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3634 .is_ok()
3635 }
3636
3637 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3643 self.command_sender
3644 .send(PluginCommand::ExecuteActions { actions })
3645 .is_ok()
3646 }
3647
3648 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3652 self.command_sender
3653 .send(PluginCommand::ShowActionPopup {
3654 popup_id: opts.id,
3655 title: opts.title,
3656 message: opts.message,
3657 actions: opts.actions,
3658 })
3659 .is_ok()
3660 }
3661
3662 pub fn disable_lsp_for_language(&self, language: String) -> bool {
3664 self.command_sender
3665 .send(PluginCommand::DisableLspForLanguage { language })
3666 .is_ok()
3667 }
3668
3669 pub fn restart_lsp_for_language(&self, language: String) -> bool {
3671 self.command_sender
3672 .send(PluginCommand::RestartLspForLanguage { language })
3673 .is_ok()
3674 }
3675
3676 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3679 self.command_sender
3680 .send(PluginCommand::SetLspRootUri { language, uri })
3681 .is_ok()
3682 }
3683
3684 #[plugin_api(ts_return = "JsDiagnostic[]")]
3686 pub fn get_all_diagnostics<'js>(
3687 &self,
3688 ctx: rquickjs::Ctx<'js>,
3689 ) -> rquickjs::Result<Value<'js>> {
3690 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3691
3692 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3693 let mut result: Vec<JsDiagnostic> = Vec::new();
3695 for (uri, diags) in s.diagnostics.iter() {
3696 for diag in diags {
3697 result.push(JsDiagnostic {
3698 uri: uri.clone(),
3699 message: diag.message.clone(),
3700 severity: diag.severity.map(|s| match s {
3701 lsp_types::DiagnosticSeverity::ERROR => 1,
3702 lsp_types::DiagnosticSeverity::WARNING => 2,
3703 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3704 lsp_types::DiagnosticSeverity::HINT => 4,
3705 _ => 0,
3706 }),
3707 range: JsRange {
3708 start: JsPosition {
3709 line: diag.range.start.line,
3710 character: diag.range.start.character,
3711 },
3712 end: JsPosition {
3713 line: diag.range.end.line,
3714 character: diag.range.end.character,
3715 },
3716 },
3717 source: diag.source.clone(),
3718 });
3719 }
3720 }
3721 result
3722 } else {
3723 Vec::new()
3724 };
3725 rquickjs_serde::to_value(ctx, &diagnostics)
3726 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3727 }
3728
3729 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3731 self.event_handlers
3732 .borrow()
3733 .get(&event_name)
3734 .cloned()
3735 .unwrap_or_default()
3736 .into_iter()
3737 .map(|h| h.handler_name)
3738 .collect()
3739 }
3740
3741 #[plugin_api(
3745 async_promise,
3746 js_name = "createVirtualBuffer",
3747 ts_return = "VirtualBufferResult"
3748 )]
3749 #[qjs(rename = "_createVirtualBufferStart")]
3750 pub fn create_virtual_buffer_start(
3751 &self,
3752 _ctx: rquickjs::Ctx<'_>,
3753 opts: fresh_core::api::CreateVirtualBufferOptions,
3754 ) -> rquickjs::Result<u64> {
3755 let id = self.alloc_request_id();
3756
3757 let entries: Vec<TextPropertyEntry> = opts
3759 .entries
3760 .unwrap_or_default()
3761 .into_iter()
3762 .map(|e| TextPropertyEntry {
3763 text: e.text,
3764 properties: e.properties.unwrap_or_default(),
3765 style: e.style,
3766 inline_overlays: e.inline_overlays.unwrap_or_default(),
3767 })
3768 .collect();
3769
3770 tracing::debug!(
3771 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3772 id
3773 );
3774 if let Ok(mut owners) = self.async_resource_owners.lock() {
3776 owners.insert(id, self.plugin_name.clone());
3777 }
3778 let _ = self
3779 .command_sender
3780 .send(PluginCommand::CreateVirtualBufferWithContent {
3781 name: opts.name,
3782 mode: opts.mode.unwrap_or_default(),
3783 read_only: opts.read_only.unwrap_or(false),
3784 entries,
3785 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3786 show_cursors: opts.show_cursors.unwrap_or(true),
3787 editing_disabled: opts.editing_disabled.unwrap_or(false),
3788 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3789 request_id: Some(id),
3790 });
3791 Ok(id)
3792 }
3793
3794 #[plugin_api(
3796 async_promise,
3797 js_name = "createVirtualBufferInSplit",
3798 ts_return = "VirtualBufferResult"
3799 )]
3800 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3801 pub fn create_virtual_buffer_in_split_start(
3802 &self,
3803 _ctx: rquickjs::Ctx<'_>,
3804 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3805 ) -> rquickjs::Result<u64> {
3806 let id = self.alloc_request_id();
3807
3808 let entries: Vec<TextPropertyEntry> = opts
3810 .entries
3811 .unwrap_or_default()
3812 .into_iter()
3813 .map(|e| TextPropertyEntry {
3814 text: e.text,
3815 properties: e.properties.unwrap_or_default(),
3816 style: e.style,
3817 inline_overlays: e.inline_overlays.unwrap_or_default(),
3818 })
3819 .collect();
3820
3821 if let Ok(mut owners) = self.async_resource_owners.lock() {
3823 owners.insert(id, self.plugin_name.clone());
3824 }
3825 let _ = self
3826 .command_sender
3827 .send(PluginCommand::CreateVirtualBufferInSplit {
3828 name: opts.name,
3829 mode: opts.mode.unwrap_or_default(),
3830 read_only: opts.read_only.unwrap_or(false),
3831 entries,
3832 ratio: opts.ratio.unwrap_or(0.5),
3833 direction: opts.direction,
3834 panel_id: opts.panel_id,
3835 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3836 show_cursors: opts.show_cursors.unwrap_or(true),
3837 editing_disabled: opts.editing_disabled.unwrap_or(false),
3838 line_wrap: opts.line_wrap,
3839 before: opts.before.unwrap_or(false),
3840 request_id: Some(id),
3841 });
3842 Ok(id)
3843 }
3844
3845 #[plugin_api(
3847 async_promise,
3848 js_name = "createVirtualBufferInExistingSplit",
3849 ts_return = "VirtualBufferResult"
3850 )]
3851 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3852 pub fn create_virtual_buffer_in_existing_split_start(
3853 &self,
3854 _ctx: rquickjs::Ctx<'_>,
3855 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3856 ) -> rquickjs::Result<u64> {
3857 let id = self.alloc_request_id();
3858
3859 let entries: Vec<TextPropertyEntry> = opts
3861 .entries
3862 .unwrap_or_default()
3863 .into_iter()
3864 .map(|e| TextPropertyEntry {
3865 text: e.text,
3866 properties: e.properties.unwrap_or_default(),
3867 style: e.style,
3868 inline_overlays: e.inline_overlays.unwrap_or_default(),
3869 })
3870 .collect();
3871
3872 if let Ok(mut owners) = self.async_resource_owners.lock() {
3874 owners.insert(id, self.plugin_name.clone());
3875 }
3876 let _ = self
3877 .command_sender
3878 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3879 name: opts.name,
3880 mode: opts.mode.unwrap_or_default(),
3881 read_only: opts.read_only.unwrap_or(false),
3882 entries,
3883 split_id: SplitId(opts.split_id),
3884 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3885 show_cursors: opts.show_cursors.unwrap_or(true),
3886 editing_disabled: opts.editing_disabled.unwrap_or(false),
3887 line_wrap: opts.line_wrap,
3888 request_id: Some(id),
3889 });
3890 Ok(id)
3891 }
3892
3893 #[qjs(rename = "_createBufferGroupStart")]
3895 pub fn create_buffer_group_start(
3896 &self,
3897 _ctx: rquickjs::Ctx<'_>,
3898 name: String,
3899 mode: String,
3900 layout_json: String,
3901 ) -> rquickjs::Result<u64> {
3902 let id = self.alloc_request_id();
3903 if let Ok(mut owners) = self.async_resource_owners.lock() {
3904 owners.insert(id, self.plugin_name.clone());
3905 }
3906 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
3907 name,
3908 mode,
3909 layout_json,
3910 request_id: Some(id),
3911 });
3912 Ok(id)
3913 }
3914
3915 #[qjs(rename = "setPanelContent")]
3917 pub fn set_panel_content<'js>(
3918 &self,
3919 ctx: rquickjs::Ctx<'js>,
3920 group_id: u32,
3921 panel_name: String,
3922 entries_arr: Vec<rquickjs::Object<'js>>,
3923 ) -> rquickjs::Result<bool> {
3924 let entries: Vec<TextPropertyEntry> = entries_arr
3925 .iter()
3926 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3927 .collect();
3928 Ok(self
3929 .command_sender
3930 .send(PluginCommand::SetPanelContent {
3931 group_id: group_id as usize,
3932 panel_name,
3933 entries,
3934 })
3935 .is_ok())
3936 }
3937
3938 #[qjs(rename = "closeBufferGroup")]
3940 pub fn close_buffer_group(&self, group_id: u32) -> bool {
3941 self.command_sender
3942 .send(PluginCommand::CloseBufferGroup {
3943 group_id: group_id as usize,
3944 })
3945 .is_ok()
3946 }
3947
3948 #[qjs(rename = "focusBufferGroupPanel")]
3950 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
3951 self.command_sender
3952 .send(PluginCommand::FocusPanel {
3953 group_id: group_id as usize,
3954 panel_name,
3955 })
3956 .is_ok()
3957 }
3958
3959 pub fn set_virtual_buffer_content<'js>(
3963 &self,
3964 ctx: rquickjs::Ctx<'js>,
3965 buffer_id: u32,
3966 entries_arr: Vec<rquickjs::Object<'js>>,
3967 ) -> rquickjs::Result<bool> {
3968 let entries: Vec<TextPropertyEntry> = entries_arr
3969 .iter()
3970 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3971 .collect();
3972 Ok(self
3973 .command_sender
3974 .send(PluginCommand::SetVirtualBufferContent {
3975 buffer_id: BufferId(buffer_id as usize),
3976 entries,
3977 })
3978 .is_ok())
3979 }
3980
3981 pub fn get_text_properties_at_cursor(
3983 &self,
3984 buffer_id: u32,
3985 ) -> fresh_core::api::TextPropertiesAtCursor {
3986 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3987 }
3988
3989 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3993 #[qjs(rename = "_spawnProcessStart")]
3994 pub fn spawn_process_start(
3995 &self,
3996 _ctx: rquickjs::Ctx<'_>,
3997 command: String,
3998 args: Vec<String>,
3999 cwd: rquickjs::function::Opt<String>,
4000 ) -> u64 {
4001 let id = self.alloc_request_id();
4002 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
4008 self.state_snapshot
4009 .read()
4010 .ok()
4011 .map(|s| s.working_dir.to_string_lossy().to_string())
4012 });
4013 tracing::info!(
4014 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
4015 self.plugin_name,
4016 command,
4017 args,
4018 effective_cwd,
4019 id
4020 );
4021 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
4022 callback_id: JsCallbackId::new(id),
4023 command,
4024 args,
4025 cwd: effective_cwd,
4026 });
4027 id
4028 }
4029
4030 #[plugin_api(
4037 async_thenable,
4038 js_name = "spawnHostProcess",
4039 ts_return = "SpawnResult"
4040 )]
4041 #[qjs(rename = "_spawnHostProcessStart")]
4042 pub fn spawn_host_process_start(
4043 &self,
4044 _ctx: rquickjs::Ctx<'_>,
4045 command: String,
4046 args: Vec<String>,
4047 cwd: rquickjs::function::Opt<String>,
4048 ) -> u64 {
4049 let id = self.alloc_request_id();
4050 let effective_cwd = cwd.0.or_else(|| {
4051 self.state_snapshot
4052 .read()
4053 .ok()
4054 .map(|s| s.working_dir.to_string_lossy().to_string())
4055 });
4056 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
4057 callback_id: JsCallbackId::new(id),
4058 command,
4059 args,
4060 cwd: effective_cwd,
4061 });
4062 id
4063 }
4064
4065 #[plugin_api(js_name = "_killHostProcess")]
4075 pub fn kill_host_process(&self, process_id: u64) -> bool {
4076 self.command_sender
4077 .send(PluginCommand::KillHostProcess { process_id })
4078 .is_ok()
4079 }
4080
4081 #[plugin_api(js_name = "setAuthority")]
4090 pub fn set_authority(
4091 &self,
4092 ctx: rquickjs::Ctx<'_>,
4093 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
4094 ) -> bool {
4095 let json = js_to_json(&ctx, payload);
4096 let _ = self
4097 .command_sender
4098 .send(PluginCommand::SetAuthority { payload: json });
4099 true
4100 }
4101
4102 #[plugin_api(js_name = "clearAuthority")]
4105 pub fn clear_authority(&self) {
4106 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
4107 }
4108
4109 #[plugin_api(js_name = "setRemoteIndicatorState")]
4127 pub fn set_remote_indicator_state(
4128 &self,
4129 ctx: rquickjs::Ctx<'_>,
4130 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
4131 ) -> bool {
4132 let json = js_to_json(&ctx, state);
4133 let _ = self
4134 .command_sender
4135 .send(PluginCommand::SetRemoteIndicatorState { state: json });
4136 true
4137 }
4138
4139 #[plugin_api(js_name = "clearRemoteIndicatorState")]
4142 pub fn clear_remote_indicator_state(&self) {
4143 let _ = self
4144 .command_sender
4145 .send(PluginCommand::ClearRemoteIndicatorState);
4146 }
4147
4148 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
4150 #[qjs(rename = "_spawnProcessWaitStart")]
4151 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
4152 let id = self.alloc_request_id();
4153 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
4154 process_id,
4155 callback_id: JsCallbackId::new(id),
4156 });
4157 id
4158 }
4159
4160 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
4162 #[qjs(rename = "_getBufferTextStart")]
4163 pub fn get_buffer_text_start(
4164 &self,
4165 _ctx: rquickjs::Ctx<'_>,
4166 buffer_id: u32,
4167 start: u32,
4168 end: u32,
4169 ) -> u64 {
4170 let id = self.alloc_request_id();
4171 let _ = self.command_sender.send(PluginCommand::GetBufferText {
4172 buffer_id: BufferId(buffer_id as usize),
4173 start: start as usize,
4174 end: end as usize,
4175 request_id: id,
4176 });
4177 id
4178 }
4179
4180 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
4182 #[qjs(rename = "_delayStart")]
4183 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
4184 let id = self.alloc_request_id();
4185 let _ = self.command_sender.send(PluginCommand::Delay {
4186 callback_id: JsCallbackId::new(id),
4187 duration_ms,
4188 });
4189 id
4190 }
4191
4192 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
4196 #[qjs(rename = "_grepProjectStart")]
4197 pub fn grep_project_start(
4198 &self,
4199 _ctx: rquickjs::Ctx<'_>,
4200 pattern: String,
4201 fixed_string: Option<bool>,
4202 case_sensitive: Option<bool>,
4203 max_results: Option<u32>,
4204 whole_words: Option<bool>,
4205 ) -> u64 {
4206 let id = self.alloc_request_id();
4207 let _ = self.command_sender.send(PluginCommand::GrepProject {
4208 pattern,
4209 fixed_string: fixed_string.unwrap_or(true),
4210 case_sensitive: case_sensitive.unwrap_or(true),
4211 max_results: max_results.unwrap_or(200) as usize,
4212 whole_words: whole_words.unwrap_or(false),
4213 callback_id: JsCallbackId::new(id),
4214 });
4215 id
4216 }
4217
4218 #[plugin_api(
4222 js_name = "grepProjectStreaming",
4223 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
4224 )]
4225 #[qjs(rename = "_grepProjectStreamingStart")]
4226 pub fn grep_project_streaming_start(
4227 &self,
4228 _ctx: rquickjs::Ctx<'_>,
4229 pattern: String,
4230 fixed_string: bool,
4231 case_sensitive: bool,
4232 max_results: u32,
4233 whole_words: bool,
4234 ) -> u64 {
4235 let id = self.alloc_request_id();
4236 let _ = self
4237 .command_sender
4238 .send(PluginCommand::GrepProjectStreaming {
4239 pattern,
4240 fixed_string,
4241 case_sensitive,
4242 max_results: max_results as usize,
4243 whole_words,
4244 search_id: id,
4245 callback_id: JsCallbackId::new(id),
4246 });
4247 id
4248 }
4249
4250 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
4254 #[qjs(rename = "_replaceInFileStart")]
4255 pub fn replace_in_file_start(
4256 &self,
4257 _ctx: rquickjs::Ctx<'_>,
4258 file_path: String,
4259 matches: Vec<Vec<u32>>,
4260 replacement: String,
4261 ) -> u64 {
4262 let id = self.alloc_request_id();
4263 let match_pairs: Vec<(usize, usize)> = matches
4265 .iter()
4266 .map(|m| (m[0] as usize, m[1] as usize))
4267 .collect();
4268 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
4269 file_path: PathBuf::from(file_path),
4270 matches: match_pairs,
4271 replacement,
4272 callback_id: JsCallbackId::new(id),
4273 });
4274 id
4275 }
4276
4277 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
4279 #[qjs(rename = "_sendLspRequestStart")]
4280 pub fn send_lsp_request_start<'js>(
4281 &self,
4282 ctx: rquickjs::Ctx<'js>,
4283 language: String,
4284 method: String,
4285 params: Option<rquickjs::Object<'js>>,
4286 ) -> rquickjs::Result<u64> {
4287 let id = self.alloc_request_id();
4288 let params_json: Option<serde_json::Value> = params.map(|obj| {
4290 let val = obj.into_value();
4291 js_to_json(&ctx, val)
4292 });
4293 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
4294 request_id: id,
4295 language,
4296 method,
4297 params: params_json,
4298 });
4299 Ok(id)
4300 }
4301
4302 #[plugin_api(
4304 async_thenable,
4305 js_name = "spawnBackgroundProcess",
4306 ts_return = "BackgroundProcessResult"
4307 )]
4308 #[qjs(rename = "_spawnBackgroundProcessStart")]
4309 pub fn spawn_background_process_start(
4310 &self,
4311 _ctx: rquickjs::Ctx<'_>,
4312 command: String,
4313 args: Vec<String>,
4314 cwd: rquickjs::function::Opt<String>,
4315 ) -> u64 {
4316 let id = self.alloc_request_id();
4317 let process_id = id;
4319 self.plugin_tracked_state
4321 .borrow_mut()
4322 .entry(self.plugin_name.clone())
4323 .or_default()
4324 .background_process_ids
4325 .push(process_id);
4326 let _ = self
4328 .command_sender
4329 .send(PluginCommand::SpawnBackgroundProcess {
4330 process_id,
4331 command,
4332 args,
4333 cwd: cwd.0.filter(|s| !s.is_empty()),
4334 callback_id: JsCallbackId::new(id),
4335 });
4336 id
4337 }
4338
4339 pub fn kill_background_process(&self, process_id: u64) -> bool {
4341 self.command_sender
4342 .send(PluginCommand::KillBackgroundProcess { process_id })
4343 .is_ok()
4344 }
4345
4346 #[plugin_api(
4350 async_promise,
4351 js_name = "createTerminal",
4352 ts_return = "TerminalResult"
4353 )]
4354 #[qjs(rename = "_createTerminalStart")]
4355 pub fn create_terminal_start(
4356 &self,
4357 _ctx: rquickjs::Ctx<'_>,
4358 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
4359 ) -> rquickjs::Result<u64> {
4360 let id = self.alloc_request_id();
4361
4362 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
4363 cwd: None,
4364 direction: None,
4365 ratio: None,
4366 focus: None,
4367 persistent: None,
4368 });
4369
4370 if let Ok(mut owners) = self.async_resource_owners.lock() {
4372 owners.insert(id, self.plugin_name.clone());
4373 }
4374 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
4375 cwd: opts.cwd,
4376 direction: opts.direction,
4377 ratio: opts.ratio,
4378 focus: opts.focus,
4379 persistent: opts.persistent.unwrap_or(false),
4383 request_id: id,
4384 });
4385 Ok(id)
4386 }
4387
4388 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
4390 self.command_sender
4391 .send(PluginCommand::SendTerminalInput {
4392 terminal_id: fresh_core::TerminalId(terminal_id as usize),
4393 data,
4394 })
4395 .is_ok()
4396 }
4397
4398 pub fn close_terminal(&self, terminal_id: u64) -> bool {
4400 self.command_sender
4401 .send(PluginCommand::CloseTerminal {
4402 terminal_id: fresh_core::TerminalId(terminal_id as usize),
4403 })
4404 .is_ok()
4405 }
4406
4407 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
4411 self.command_sender
4412 .send(PluginCommand::RefreshLines {
4413 buffer_id: BufferId(buffer_id as usize),
4414 })
4415 .is_ok()
4416 }
4417
4418 pub fn get_current_locale(&self) -> String {
4420 self.services.current_locale()
4421 }
4422
4423 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
4427 #[qjs(rename = "_loadPluginStart")]
4428 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
4429 let id = self.alloc_request_id();
4430 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
4431 path: std::path::PathBuf::from(path),
4432 callback_id: JsCallbackId::new(id),
4433 });
4434 id
4435 }
4436
4437 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
4439 #[qjs(rename = "_unloadPluginStart")]
4440 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4441 let id = self.alloc_request_id();
4442 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
4443 name,
4444 callback_id: JsCallbackId::new(id),
4445 });
4446 id
4447 }
4448
4449 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
4451 #[qjs(rename = "_reloadPluginStart")]
4452 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4453 let id = self.alloc_request_id();
4454 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
4455 name,
4456 callback_id: JsCallbackId::new(id),
4457 });
4458 id
4459 }
4460
4461 #[plugin_api(
4464 async_promise,
4465 js_name = "listPlugins",
4466 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
4467 )]
4468 #[qjs(rename = "_listPluginsStart")]
4469 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4470 let id = self.alloc_request_id();
4471 let _ = self.command_sender.send(PluginCommand::ListPlugins {
4472 callback_id: JsCallbackId::new(id),
4473 });
4474 id
4475 }
4476}
4477
4478fn parse_view_token(
4485 obj: &rquickjs::Object<'_>,
4486 idx: usize,
4487) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
4488 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4489
4490 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
4492 from: "object",
4493 to: "ViewTokenWire",
4494 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
4495 })?;
4496
4497 let source_offset: Option<usize> = obj
4499 .get("sourceOffset")
4500 .ok()
4501 .or_else(|| obj.get("source_offset").ok());
4502
4503 let kind = if kind_value.is_string() {
4505 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4508 from: "value",
4509 to: "string",
4510 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
4511 })?;
4512
4513 match kind_str.to_lowercase().as_str() {
4514 "text" => {
4515 let text: String = obj.get("text").unwrap_or_default();
4516 ViewTokenWireKind::Text(text)
4517 }
4518 "newline" => ViewTokenWireKind::Newline,
4519 "space" => ViewTokenWireKind::Space,
4520 "break" => ViewTokenWireKind::Break,
4521 _ => {
4522 tracing::warn!(
4524 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
4525 idx, kind_str
4526 );
4527 return Err(rquickjs::Error::FromJs {
4528 from: "string",
4529 to: "ViewTokenWireKind",
4530 message: Some(format!(
4531 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
4532 idx, kind_str
4533 )),
4534 });
4535 }
4536 }
4537 } else if kind_value.is_object() {
4538 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4540 from: "value",
4541 to: "object",
4542 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
4543 })?;
4544
4545 if let Ok(text) = kind_obj.get::<_, String>("Text") {
4546 ViewTokenWireKind::Text(text)
4547 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
4548 ViewTokenWireKind::BinaryByte(byte)
4549 } else {
4550 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
4552 tracing::warn!(
4553 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
4554 idx,
4555 keys
4556 );
4557 return Err(rquickjs::Error::FromJs {
4558 from: "object",
4559 to: "ViewTokenWireKind",
4560 message: Some(format!(
4561 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
4562 idx, keys
4563 )),
4564 });
4565 }
4566 } else {
4567 tracing::warn!(
4568 "token[{}]: 'kind' field must be a string or object, got: {:?}",
4569 idx,
4570 kind_value.type_of()
4571 );
4572 return Err(rquickjs::Error::FromJs {
4573 from: "value",
4574 to: "ViewTokenWireKind",
4575 message: Some(format!(
4576 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
4577 idx
4578 )),
4579 });
4580 };
4581
4582 let style = parse_view_token_style(obj, idx)?;
4584
4585 Ok(ViewTokenWire {
4586 source_offset,
4587 kind,
4588 style,
4589 })
4590}
4591
4592fn parse_view_token_style(
4594 obj: &rquickjs::Object<'_>,
4595 idx: usize,
4596) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
4597 use fresh_core::api::ViewTokenStyle;
4598
4599 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
4600 let Some(s) = style_obj else {
4601 return Ok(None);
4602 };
4603
4604 let fg: Option<Vec<u8>> = s.get("fg").ok();
4605 let bg: Option<Vec<u8>> = s.get("bg").ok();
4606
4607 let fg_color = if let Some(ref c) = fg {
4609 if c.len() < 3 {
4610 tracing::warn!(
4611 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4612 idx,
4613 c.len()
4614 );
4615 None
4616 } else {
4617 Some((c[0], c[1], c[2]))
4618 }
4619 } else {
4620 None
4621 };
4622
4623 let bg_color = if let Some(ref c) = bg {
4624 if c.len() < 3 {
4625 tracing::warn!(
4626 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4627 idx,
4628 c.len()
4629 );
4630 None
4631 } else {
4632 Some((c[0], c[1], c[2]))
4633 }
4634 } else {
4635 None
4636 };
4637
4638 Ok(Some(ViewTokenStyle {
4639 fg: fg_color,
4640 bg: bg_color,
4641 bold: s.get("bold").unwrap_or(false),
4642 italic: s.get("italic").unwrap_or(false),
4643 }))
4644}
4645
4646pub struct QuickJsBackend {
4648 runtime: Runtime,
4649 main_context: Context,
4651 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4653 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4655 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4657 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4659 command_sender: mpsc::Sender<PluginCommand>,
4661 #[allow(dead_code)]
4663 pending_responses: PendingResponses,
4664 next_request_id: Rc<RefCell<u64>>,
4666 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4668 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4670 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4672 async_resource_owners: AsyncResourceOwners,
4675 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4677 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4679 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4681 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4683 plugin_api_exports:
4687 Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>,
4688}
4689
4690impl Drop for QuickJsBackend {
4691 fn drop(&mut self) {
4692 self.plugin_api_exports.borrow_mut().clear();
4698 }
4699}
4700
4701impl QuickJsBackend {
4702 pub fn new() -> Result<Self> {
4704 let (tx, _rx) = mpsc::channel();
4705 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4706 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4707 Self::with_state(state_snapshot, tx, services)
4708 }
4709
4710 pub fn with_state(
4712 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4713 command_sender: mpsc::Sender<PluginCommand>,
4714 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4715 ) -> Result<Self> {
4716 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4717 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4718 }
4719
4720 pub fn with_state_and_responses(
4722 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4723 command_sender: mpsc::Sender<PluginCommand>,
4724 pending_responses: PendingResponses,
4725 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4726 ) -> Result<Self> {
4727 let async_resource_owners: AsyncResourceOwners =
4728 Arc::new(std::sync::Mutex::new(HashMap::new()));
4729 Self::with_state_responses_and_resources(
4730 state_snapshot,
4731 command_sender,
4732 pending_responses,
4733 services,
4734 async_resource_owners,
4735 )
4736 }
4737
4738 pub fn with_state_responses_and_resources(
4741 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4742 command_sender: mpsc::Sender<PluginCommand>,
4743 pending_responses: PendingResponses,
4744 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4745 async_resource_owners: AsyncResourceOwners,
4746 ) -> Result<Self> {
4747 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4748
4749 let runtime =
4750 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4751
4752 runtime.set_host_promise_rejection_tracker(Some(Box::new(
4754 |_ctx, _promise, reason, is_handled| {
4755 if !is_handled {
4756 let error_msg = if let Some(exc) = reason.as_exception() {
4758 format!(
4759 "{}: {}",
4760 exc.message().unwrap_or_default(),
4761 exc.stack().unwrap_or_default()
4762 )
4763 } else {
4764 format!("{:?}", reason)
4765 };
4766
4767 tracing::error!("Unhandled Promise rejection: {}", error_msg);
4768
4769 if should_panic_on_js_errors() {
4770 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4773 set_fatal_js_error(full_msg);
4774 }
4775 }
4776 },
4777 )));
4778
4779 let main_context = Context::full(&runtime)
4780 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4781
4782 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4783 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4784 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4785 let next_request_id = Rc::new(RefCell::new(1u64));
4786 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4787 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4788 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4789 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4790 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4791 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4792 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
4793
4794 let backend = Self {
4795 runtime,
4796 main_context,
4797 plugin_contexts,
4798 event_handlers,
4799 registered_actions,
4800 state_snapshot,
4801 command_sender,
4802 pending_responses,
4803 next_request_id,
4804 callback_contexts,
4805 services,
4806 plugin_tracked_state,
4807 async_resource_owners,
4808 registered_command_names,
4809 registered_grammar_languages,
4810 registered_language_configs,
4811 registered_lsp_servers,
4812 plugin_api_exports,
4813 };
4814
4815 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4817
4818 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4819 Ok(backend)
4820 }
4821
4822 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4824 let state_snapshot = Arc::clone(&self.state_snapshot);
4825 let command_sender = self.command_sender.clone();
4826 let event_handlers = Rc::clone(&self.event_handlers);
4827 let registered_actions = Rc::clone(&self.registered_actions);
4828 let next_request_id = Rc::clone(&self.next_request_id);
4829 let registered_command_names = Rc::clone(&self.registered_command_names);
4830 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4831 let registered_language_configs = Rc::clone(&self.registered_language_configs);
4832 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4833 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
4834
4835 context.with(|ctx| {
4836 let globals = ctx.globals();
4837
4838 globals.set("__pluginName__", plugin_name)?;
4840
4841 let js_api = JsEditorApi {
4844 state_snapshot: Arc::clone(&state_snapshot),
4845 command_sender: command_sender.clone(),
4846 registered_actions: Rc::clone(®istered_actions),
4847 event_handlers: Rc::clone(&event_handlers),
4848 next_request_id: Rc::clone(&next_request_id),
4849 callback_contexts: Rc::clone(&self.callback_contexts),
4850 services: self.services.clone(),
4851 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4852 async_resource_owners: Arc::clone(&self.async_resource_owners),
4853 registered_command_names: Rc::clone(®istered_command_names),
4854 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
4855 registered_language_configs: Rc::clone(®istered_language_configs),
4856 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
4857 plugin_api_exports: Rc::clone(&plugin_api_exports),
4858 plugin_name: plugin_name.to_string(),
4859 };
4860 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4861
4862 globals.set("editor", editor)?;
4864
4865 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4867
4868 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4870
4871ctx.eval::<(), _>(
4878 r#"
4879 (function() {
4880 const originalOn = editor.on.bind(editor);
4881 const originalOff = editor.off.bind(editor);
4882 let counter = 0;
4883 const anonNames = new WeakMap();
4884 editor.on = function(eventName, handlerOrName) {
4885 if (typeof handlerOrName === 'function') {
4886 const existing = anonNames.get(handlerOrName);
4887 const name = existing || `__anon_on_${++counter}`;
4888 if (!existing) {
4889 anonNames.set(handlerOrName, name);
4890 }
4891 globalThis[name] = handlerOrName;
4892 return originalOn(eventName, name);
4893 }
4894 return originalOn(eventName, handlerOrName);
4895 };
4896 editor.off = function(eventName, handlerOrName) {
4897 if (typeof handlerOrName === 'function') {
4898 const name = anonNames.get(handlerOrName);
4899 if (name === undefined) return false;
4900 return originalOff(eventName, name);
4901 }
4902 return originalOff(eventName, handlerOrName);
4903 };
4904 })();
4905 "#,
4906 )?;
4907
4908 let console = Object::new(ctx.clone())?;
4911 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4912 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4913 tracing::info!("console.log: {}", parts.join(" "));
4914 })?)?;
4915 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4916 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4917 tracing::warn!("console.warn: {}", parts.join(" "));
4918 })?)?;
4919 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4920 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4921 tracing::error!("console.error: {}", parts.join(" "));
4922 })?)?;
4923 globals.set("console", console)?;
4924
4925 ctx.eval::<(), _>(r#"
4927 // Pending promise callbacks: callbackId -> { resolve, reject }
4928 globalThis._pendingCallbacks = new Map();
4929
4930 // Resolve a pending callback (called from Rust)
4931 globalThis._resolveCallback = function(callbackId, result) {
4932 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4933 const cb = globalThis._pendingCallbacks.get(callbackId);
4934 if (cb) {
4935 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4936 globalThis._pendingCallbacks.delete(callbackId);
4937 cb.resolve(result);
4938 console.log('[JS] _resolveCallback: resolve() called');
4939 } else {
4940 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4941 }
4942 };
4943
4944 // Reject a pending callback (called from Rust)
4945 globalThis._rejectCallback = function(callbackId, error) {
4946 const cb = globalThis._pendingCallbacks.get(callbackId);
4947 if (cb) {
4948 globalThis._pendingCallbacks.delete(callbackId);
4949 cb.reject(new Error(error));
4950 }
4951 };
4952
4953 // Streaming callbacks: called multiple times with partial results
4954 globalThis._streamingCallbacks = new Map();
4955
4956 // Called from Rust with partial data. When done=true, cleans up.
4957 globalThis._callStreamingCallback = function(callbackId, result, done) {
4958 const cb = globalThis._streamingCallbacks.get(callbackId);
4959 if (cb) {
4960 cb(result, done);
4961 if (done) {
4962 globalThis._streamingCallbacks.delete(callbackId);
4963 }
4964 }
4965 };
4966
4967 // Generic async wrapper decorator
4968 // Wraps a function that returns a callbackId into a promise-returning function
4969 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4970 // NOTE: We pass the method name as a string and call via bracket notation
4971 // to preserve rquickjs's automatic Ctx injection for methods
4972 globalThis._wrapAsync = function(methodName, fnName) {
4973 const startFn = editor[methodName];
4974 if (typeof startFn !== 'function') {
4975 // Return a function that always throws - catches missing implementations
4976 return function(...args) {
4977 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4978 editor.debug(`[ASYNC ERROR] ${error.message}`);
4979 throw error;
4980 };
4981 }
4982 return function(...args) {
4983 // Call via bracket notation to preserve method binding and Ctx injection
4984 const callbackId = editor[methodName](...args);
4985 return new Promise((resolve, reject) => {
4986 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4987 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4988 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4989 });
4990 };
4991 };
4992
4993 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4994 // The returned object has .result promise and is itself thenable
4995 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4996 const startFn = editor[methodName];
4997 if (typeof startFn !== 'function') {
4998 // Return a function that always throws - catches missing implementations
4999 return function(...args) {
5000 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
5001 editor.debug(`[ASYNC ERROR] ${error.message}`);
5002 throw error;
5003 };
5004 }
5005 return function(...args) {
5006 // Call via bracket notation to preserve method binding and Ctx injection
5007 const callbackId = editor[methodName](...args);
5008 const resultPromise = new Promise((resolve, reject) => {
5009 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
5010 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
5011 });
5012 return {
5013 get result() { return resultPromise; },
5014 then(onFulfilled, onRejected) {
5015 return resultPromise.then(onFulfilled, onRejected);
5016 },
5017 catch(onRejected) {
5018 return resultPromise.catch(onRejected);
5019 }
5020 };
5021 };
5022 };
5023
5024 // Apply wrappers to async functions on editor
5025 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
5026 // spawnHostProcess gets a bespoke wrapper (instead of
5027 // `_wrapAsyncThenable`) because its `ProcessHandle`
5028 // exposes a real `kill()` that forwards to
5029 // `_killHostProcess`. Generic wrap has no hook for
5030 // that.
5031 editor.spawnHostProcess = function(command, args, cwd) {
5032 if (typeof editor._spawnHostProcessStart !== 'function') {
5033 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
5034 }
5035 // Pass real strings only. Earlier revisions forwarded
5036 // `""` for a missing cwd, which landed verbatim as
5037 // `Command::current_dir("")` in the dispatcher —
5038 // every host-spawn then failed with ENOENT. Use two
5039 // arity forms so the Rust `Opt<String>` stays `None`
5040 // instead of `Some("")`.
5041 let callbackId;
5042 if (typeof cwd === "string" && cwd.length > 0) {
5043 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
5044 } else {
5045 callbackId = editor._spawnHostProcessStart(command, args || []);
5046 }
5047 const resultPromise = new Promise(function(resolve, reject) {
5048 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
5049 });
5050 return {
5051 processId: callbackId,
5052 get result() { return resultPromise; },
5053 then: function(f, r) { return resultPromise.then(f, r); },
5054 catch: function(r) { return resultPromise.catch(r); },
5055 kill: function() {
5056 // Returns true when the kill was enqueued
5057 // (the process may have already exited; in
5058 // that case the dispatcher silently
5059 // drops it). Matches the
5060 // `ProcessHandle.kill(): Promise<boolean>`
5061 // type signature by wrapping the sync
5062 // boolean in a Promise.
5063 return Promise.resolve(editor._killHostProcess(callbackId));
5064 }
5065 };
5066 };
5067 editor.delay = _wrapAsync("_delayStart", "delay");
5068 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
5069 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
5070 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
5071 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
5072 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
5073 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
5074 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
5075 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
5076 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
5077 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
5078 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
5079 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
5080 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
5081 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
5082 editor.prompt = _wrapAsync("_promptStart", "prompt");
5083 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
5084 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
5085 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
5086 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
5087 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
5088 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
5089 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
5090
5091 // Streaming grep: takes a progress callback, returns a thenable with searchId
5092 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
5093 opts = opts || {};
5094 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
5095 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
5096 const maxResults = opts.maxResults || 10000;
5097 const wholeWords = opts.wholeWords || false;
5098
5099 const searchId = editor._grepProjectStreamingStart(
5100 pattern, fixedString, caseSensitive, maxResults, wholeWords
5101 );
5102
5103 // Register streaming callback
5104 if (progressCallback) {
5105 globalThis._streamingCallbacks.set(searchId, progressCallback);
5106 }
5107
5108 // Create completion promise (resolved via _resolveCallback when search finishes)
5109 const resultPromise = new Promise(function(resolve, reject) {
5110 globalThis._pendingCallbacks.set(searchId, {
5111 resolve: function(result) {
5112 globalThis._streamingCallbacks.delete(searchId);
5113 resolve(result);
5114 },
5115 reject: function(err) {
5116 globalThis._streamingCallbacks.delete(searchId);
5117 reject(err);
5118 }
5119 });
5120 });
5121
5122 return {
5123 searchId: searchId,
5124 get result() { return resultPromise; },
5125 then: function(f, r) { return resultPromise.then(f, r); },
5126 catch: function(r) { return resultPromise.catch(r); }
5127 };
5128 };
5129
5130 // Wrapper for deleteTheme - wraps sync function in Promise
5131 editor.deleteTheme = function(name) {
5132 return new Promise(function(resolve, reject) {
5133 const success = editor._deleteThemeSync(name);
5134 if (success) {
5135 resolve();
5136 } else {
5137 reject(new Error("Failed to delete theme: " + name));
5138 }
5139 });
5140 };
5141 "#.as_bytes())?;
5142
5143 Ok::<_, rquickjs::Error>(())
5144 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
5145
5146 Ok(())
5147 }
5148
5149 pub async fn load_module_with_source(
5151 &mut self,
5152 path: &str,
5153 _plugin_source: &str,
5154 ) -> Result<()> {
5155 let path_buf = PathBuf::from(path);
5156 let source = std::fs::read_to_string(&path_buf)
5157 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
5158
5159 let filename = path_buf
5160 .file_name()
5161 .and_then(|s| s.to_str())
5162 .unwrap_or("plugin.ts");
5163
5164 if has_es_imports(&source) {
5166 match bundle_module(&path_buf) {
5168 Ok(bundled) => {
5169 self.execute_js(&bundled, path)?;
5170 }
5171 Err(e) => {
5172 tracing::warn!(
5173 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
5174 path,
5175 e
5176 );
5177 return Ok(()); }
5179 }
5180 } else if has_es_module_syntax(&source) {
5181 let stripped = strip_imports_and_exports(&source);
5183 let js_code = if filename.ends_with(".ts") {
5184 transpile_typescript(&stripped, filename)?
5185 } else {
5186 stripped
5187 };
5188 self.execute_js(&js_code, path)?;
5189 } else {
5190 let js_code = if filename.ends_with(".ts") {
5192 transpile_typescript(&source, filename)?
5193 } else {
5194 source
5195 };
5196 self.execute_js(&js_code, path)?;
5197 }
5198
5199 Ok(())
5200 }
5201
5202 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
5204 let plugin_name = Path::new(source_name)
5206 .file_stem()
5207 .and_then(|s| s.to_str())
5208 .unwrap_or("unknown");
5209
5210 tracing::debug!(
5211 "execute_js: starting for plugin '{}' from '{}'",
5212 plugin_name,
5213 source_name
5214 );
5215
5216 let context = {
5218 let mut contexts = self.plugin_contexts.borrow_mut();
5219 if let Some(ctx) = contexts.get(plugin_name) {
5220 ctx.clone()
5221 } else {
5222 let ctx = Context::full(&self.runtime).map_err(|e| {
5223 anyhow!(
5224 "Failed to create QuickJS context for plugin {}: {}",
5225 plugin_name,
5226 e
5227 )
5228 })?;
5229 self.setup_context_api(&ctx, plugin_name)?;
5230 contexts.insert(plugin_name.to_string(), ctx.clone());
5231 ctx
5232 }
5233 };
5234
5235 let wrapped_code = format!("(function() {{ {} }})();", code);
5239 let wrapped = wrapped_code.as_str();
5240
5241 context.with(|ctx| {
5242 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
5243
5244 let mut eval_options = rquickjs::context::EvalOptions::default();
5246 eval_options.global = true;
5247 eval_options.filename = Some(source_name.to_string());
5248 let result = ctx
5249 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
5250 .map_err(|e| format_js_error(&ctx, e, source_name));
5251
5252 tracing::debug!(
5253 "execute_js: plugin code execution finished for '{}', result: {:?}",
5254 plugin_name,
5255 result.is_ok()
5256 );
5257
5258 result
5259 })
5260 }
5261
5262 pub fn execute_source(
5268 &mut self,
5269 source: &str,
5270 plugin_name: &str,
5271 is_typescript: bool,
5272 ) -> Result<()> {
5273 use fresh_parser_js::{
5274 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
5275 };
5276
5277 if has_es_imports(source) {
5278 tracing::warn!(
5279 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
5280 plugin_name
5281 );
5282 }
5283
5284 let js_code = if has_es_module_syntax(source) {
5285 let stripped = strip_imports_and_exports(source);
5286 if is_typescript {
5287 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
5288 } else {
5289 stripped
5290 }
5291 } else if is_typescript {
5292 transpile_typescript(source, &format!("{}.ts", plugin_name))?
5293 } else {
5294 source.to_string()
5295 };
5296
5297 let source_name = format!(
5299 "{}.{}",
5300 plugin_name,
5301 if is_typescript { "ts" } else { "js" }
5302 );
5303 self.execute_js(&js_code, &source_name)
5304 }
5305
5306 pub fn cleanup_plugin(&self, plugin_name: &str) {
5312 self.plugin_contexts.borrow_mut().remove(plugin_name);
5314
5315 for handlers in self.event_handlers.borrow_mut().values_mut() {
5317 handlers.retain(|h| h.plugin_name != plugin_name);
5318 }
5319
5320 self.registered_actions
5322 .borrow_mut()
5323 .retain(|_, h| h.plugin_name != plugin_name);
5324
5325 self.callback_contexts
5327 .borrow_mut()
5328 .retain(|_, pname| pname != plugin_name);
5329
5330 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
5332 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
5334 std::collections::HashSet::new();
5335 for (buf_id, ns) in &tracked.overlay_namespaces {
5336 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
5337 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
5339 buffer_id: *buf_id,
5340 namespace: OverlayNamespace::from_string(ns.clone()),
5341 });
5342 let _ = self
5344 .command_sender
5345 .send(PluginCommand::ClearConcealNamespace {
5346 buffer_id: *buf_id,
5347 namespace: OverlayNamespace::from_string(ns.clone()),
5348 });
5349 let _ = self
5350 .command_sender
5351 .send(PluginCommand::ClearSoftBreakNamespace {
5352 buffer_id: *buf_id,
5353 namespace: OverlayNamespace::from_string(ns.clone()),
5354 });
5355 }
5356 }
5357
5358 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
5364 std::collections::HashSet::new();
5365 for (buf_id, ns) in &tracked.line_indicator_namespaces {
5366 if seen_li_ns.insert((buf_id.0, ns.clone())) {
5367 let _ = self
5368 .command_sender
5369 .send(PluginCommand::ClearLineIndicators {
5370 buffer_id: *buf_id,
5371 namespace: ns.clone(),
5372 });
5373 }
5374 }
5375
5376 let mut seen_vt: std::collections::HashSet<(usize, String)> =
5378 std::collections::HashSet::new();
5379 for (buf_id, vt_id) in &tracked.virtual_text_ids {
5380 if seen_vt.insert((buf_id.0, vt_id.clone())) {
5381 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
5382 buffer_id: *buf_id,
5383 virtual_text_id: vt_id.clone(),
5384 });
5385 }
5386 }
5387
5388 let mut seen_fe_ns: std::collections::HashSet<String> =
5390 std::collections::HashSet::new();
5391 for ns in &tracked.file_explorer_namespaces {
5392 if seen_fe_ns.insert(ns.clone()) {
5393 let _ = self
5394 .command_sender
5395 .send(PluginCommand::ClearFileExplorerDecorations {
5396 namespace: ns.clone(),
5397 });
5398 }
5399 }
5400
5401 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
5403 for ctx_name in &tracked.contexts_set {
5404 if seen_ctx.insert(ctx_name.clone()) {
5405 let _ = self.command_sender.send(PluginCommand::SetContext {
5406 name: ctx_name.clone(),
5407 active: false,
5408 });
5409 }
5410 }
5411
5412 for process_id in &tracked.background_process_ids {
5416 let _ = self
5417 .command_sender
5418 .send(PluginCommand::KillBackgroundProcess {
5419 process_id: *process_id,
5420 });
5421 }
5422
5423 for group_id in &tracked.scroll_sync_group_ids {
5425 let _ = self
5426 .command_sender
5427 .send(PluginCommand::RemoveScrollSyncGroup {
5428 group_id: *group_id,
5429 });
5430 }
5431
5432 for buffer_id in &tracked.virtual_buffer_ids {
5434 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
5435 buffer_id: *buffer_id,
5436 });
5437 }
5438
5439 for buffer_id in &tracked.composite_buffer_ids {
5441 let _ = self
5442 .command_sender
5443 .send(PluginCommand::CloseCompositeBuffer {
5444 buffer_id: *buffer_id,
5445 });
5446 }
5447
5448 for terminal_id in &tracked.terminal_ids {
5450 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
5451 terminal_id: *terminal_id,
5452 });
5453 }
5454 }
5455
5456 if let Ok(mut owners) = self.async_resource_owners.lock() {
5458 owners.retain(|_, name| name != plugin_name);
5459 }
5460
5461 self.plugin_api_exports
5463 .borrow_mut()
5464 .retain(|_, (exporter, _)| exporter != plugin_name);
5465
5466 self.registered_command_names
5468 .borrow_mut()
5469 .retain(|_, pname| pname != plugin_name);
5470 self.registered_grammar_languages
5471 .borrow_mut()
5472 .retain(|_, pname| pname != plugin_name);
5473 self.registered_language_configs
5474 .borrow_mut()
5475 .retain(|_, pname| pname != plugin_name);
5476 self.registered_lsp_servers
5477 .borrow_mut()
5478 .retain(|_, pname| pname != plugin_name);
5479
5480 tracing::debug!(
5481 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
5482 plugin_name
5483 );
5484 }
5485
5486 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
5488 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
5489
5490 self.services
5491 .set_js_execution_state(format!("hook '{}'", event_name));
5492
5493 let handlers = self.event_handlers.borrow().get(event_name).cloned();
5494 if let Some(handler_pairs) = handlers {
5495 let plugin_contexts = self.plugin_contexts.borrow();
5496 for handler in &handler_pairs {
5497 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
5498 continue;
5499 };
5500 context.with(|ctx| {
5501 call_handler(&ctx, &handler.handler_name, event_data);
5502 });
5503 }
5504 }
5505
5506 self.services.clear_js_execution_state();
5507 Ok(true)
5508 }
5509
5510 pub fn has_handlers(&self, event_name: &str) -> bool {
5512 self.event_handlers
5513 .borrow()
5514 .get(event_name)
5515 .map(|v| !v.is_empty())
5516 .unwrap_or(false)
5517 }
5518
5519 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
5523 let (lookup_name, text_input_char) =
5526 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
5527 ("mode_text_input", Some(ch.to_string()))
5528 } else {
5529 (action_name, None)
5530 };
5531
5532 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
5533 let (plugin_name, function_name) = match pair {
5534 Some(handler) => (handler.plugin_name, handler.handler_name),
5535 None => ("main".to_string(), lookup_name.to_string()),
5536 };
5537
5538 let plugin_contexts = self.plugin_contexts.borrow();
5539 let context = plugin_contexts
5540 .get(&plugin_name)
5541 .unwrap_or(&self.main_context);
5542
5543 self.services
5545 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
5546
5547 tracing::info!(
5548 "start_action: BEGIN '{}' -> function '{}'",
5549 action_name,
5550 function_name
5551 );
5552
5553 let call_args = if let Some(ref ch) = text_input_char {
5556 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
5557 format!("({{text:\"{}\"}})", escaped)
5558 } else {
5559 "()".to_string()
5560 };
5561
5562 let code = format!(
5563 r#"
5564 (function() {{
5565 console.log('[JS] start_action: calling {fn}');
5566 try {{
5567 if (typeof globalThis.{fn} === 'function') {{
5568 console.log('[JS] start_action: {fn} is a function, invoking...');
5569 globalThis.{fn}{args};
5570 console.log('[JS] start_action: {fn} invoked (may be async)');
5571 }} else {{
5572 console.error('[JS] Action {action} is not defined as a global function');
5573 }}
5574 }} catch (e) {{
5575 console.error('[JS] Action {action} error:', e);
5576 }}
5577 }})();
5578 "#,
5579 fn = function_name,
5580 action = action_name,
5581 args = call_args
5582 );
5583
5584 tracing::info!("start_action: evaluating JS code");
5585 context.with(|ctx| {
5586 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5587 log_js_error(&ctx, e, &format!("action {}", action_name));
5588 }
5589 tracing::info!("start_action: running pending microtasks");
5590 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
5592 tracing::info!("start_action: executed {} pending jobs", count);
5593 });
5594
5595 tracing::info!("start_action: END '{}'", action_name);
5596
5597 self.services.clear_js_execution_state();
5599
5600 Ok(())
5601 }
5602
5603 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
5605 let pair = self.registered_actions.borrow().get(action_name).cloned();
5607 let (plugin_name, function_name) = match pair {
5608 Some(handler) => (handler.plugin_name, handler.handler_name),
5609 None => ("main".to_string(), action_name.to_string()),
5610 };
5611
5612 let plugin_contexts = self.plugin_contexts.borrow();
5613 let context = plugin_contexts
5614 .get(&plugin_name)
5615 .unwrap_or(&self.main_context);
5616
5617 tracing::debug!(
5618 "execute_action: '{}' -> function '{}'",
5619 action_name,
5620 function_name
5621 );
5622
5623 let code = format!(
5626 r#"
5627 (async function() {{
5628 try {{
5629 if (typeof globalThis.{fn} === 'function') {{
5630 const result = globalThis.{fn}();
5631 // If it's a Promise, await it
5632 if (result && typeof result.then === 'function') {{
5633 await result;
5634 }}
5635 }} else {{
5636 console.error('Action {action} is not defined as a global function');
5637 }}
5638 }} catch (e) {{
5639 console.error('Action {action} error:', e);
5640 }}
5641 }})();
5642 "#,
5643 fn = function_name,
5644 action = action_name
5645 );
5646
5647 context.with(|ctx| {
5648 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5650 Ok(value) => {
5651 if value.is_object() {
5653 if let Some(obj) = value.as_object() {
5654 if obj.get::<_, rquickjs::Function>("then").is_ok() {
5656 run_pending_jobs_checked(
5659 &ctx,
5660 &format!("execute_action {} promise", action_name),
5661 );
5662 }
5663 }
5664 }
5665 }
5666 Err(e) => {
5667 log_js_error(&ctx, e, &format!("action {}", action_name));
5668 }
5669 }
5670 });
5671
5672 Ok(())
5673 }
5674
5675 pub fn poll_event_loop_once(&mut self) -> bool {
5677 let mut had_work = false;
5678
5679 self.main_context.with(|ctx| {
5681 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
5682 if count > 0 {
5683 had_work = true;
5684 }
5685 });
5686
5687 let contexts = self.plugin_contexts.borrow().clone();
5689 for (name, context) in contexts {
5690 context.with(|ctx| {
5691 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
5692 if count > 0 {
5693 had_work = true;
5694 }
5695 });
5696 }
5697 had_work
5698 }
5699
5700 pub fn send_status(&self, message: String) {
5702 let _ = self
5703 .command_sender
5704 .send(PluginCommand::SetStatus { message });
5705 }
5706
5707 pub fn send_hook_completed(&self, hook_name: String) {
5711 let _ = self
5712 .command_sender
5713 .send(PluginCommand::HookCompleted { hook_name });
5714 }
5715
5716 pub fn resolve_callback(
5721 &mut self,
5722 callback_id: fresh_core::api::JsCallbackId,
5723 result_json: &str,
5724 ) {
5725 let id = callback_id.as_u64();
5726 tracing::debug!("resolve_callback: starting for callback_id={}", id);
5727
5728 let plugin_name = {
5730 let mut contexts = self.callback_contexts.borrow_mut();
5731 contexts.remove(&id)
5732 };
5733
5734 let Some(name) = plugin_name else {
5735 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5736 return;
5737 };
5738
5739 let plugin_contexts = self.plugin_contexts.borrow();
5740 let Some(context) = plugin_contexts.get(&name) else {
5741 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5742 return;
5743 };
5744
5745 context.with(|ctx| {
5746 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5748 Ok(v) => v,
5749 Err(e) => {
5750 tracing::error!(
5751 "resolve_callback: failed to parse JSON for callback_id={}: {}",
5752 id,
5753 e
5754 );
5755 return;
5756 }
5757 };
5758
5759 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5761 Ok(v) => v,
5762 Err(e) => {
5763 tracing::error!(
5764 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5765 id,
5766 e
5767 );
5768 return;
5769 }
5770 };
5771
5772 let globals = ctx.globals();
5774 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5775 Ok(f) => f,
5776 Err(e) => {
5777 tracing::error!(
5778 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5779 id,
5780 e
5781 );
5782 return;
5783 }
5784 };
5785
5786 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5788 log_js_error(&ctx, e, &format!("resolving callback {}", id));
5789 }
5790
5791 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5793 tracing::info!(
5794 "resolve_callback: executed {} pending jobs for callback_id={}",
5795 job_count,
5796 id
5797 );
5798 });
5799 }
5800
5801 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5803 let id = callback_id.as_u64();
5804
5805 let plugin_name = {
5807 let mut contexts = self.callback_contexts.borrow_mut();
5808 contexts.remove(&id)
5809 };
5810
5811 let Some(name) = plugin_name else {
5812 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5813 return;
5814 };
5815
5816 let plugin_contexts = self.plugin_contexts.borrow();
5817 let Some(context) = plugin_contexts.get(&name) else {
5818 tracing::warn!("reject_callback: Context lost for plugin {}", name);
5819 return;
5820 };
5821
5822 context.with(|ctx| {
5823 let globals = ctx.globals();
5825 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5826 Ok(f) => f,
5827 Err(e) => {
5828 tracing::error!(
5829 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5830 id,
5831 e
5832 );
5833 return;
5834 }
5835 };
5836
5837 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5839 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5840 }
5841
5842 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5844 });
5845 }
5846
5847 pub fn call_streaming_callback(
5851 &mut self,
5852 callback_id: fresh_core::api::JsCallbackId,
5853 result_json: &str,
5854 done: bool,
5855 ) {
5856 let id = callback_id.as_u64();
5857
5858 let plugin_name = {
5860 let contexts = self.callback_contexts.borrow();
5861 contexts.get(&id).cloned()
5862 };
5863
5864 let Some(name) = plugin_name else {
5865 tracing::warn!(
5866 "call_streaming_callback: No plugin found for callback_id={}",
5867 id
5868 );
5869 return;
5870 };
5871
5872 if done {
5874 self.callback_contexts.borrow_mut().remove(&id);
5875 }
5876
5877 let plugin_contexts = self.plugin_contexts.borrow();
5878 let Some(context) = plugin_contexts.get(&name) else {
5879 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5880 return;
5881 };
5882
5883 context.with(|ctx| {
5884 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5885 Ok(v) => v,
5886 Err(e) => {
5887 tracing::error!(
5888 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5889 id,
5890 e
5891 );
5892 return;
5893 }
5894 };
5895
5896 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5897 Ok(v) => v,
5898 Err(e) => {
5899 tracing::error!(
5900 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5901 id,
5902 e
5903 );
5904 return;
5905 }
5906 };
5907
5908 let globals = ctx.globals();
5909 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5910 Ok(f) => f,
5911 Err(e) => {
5912 tracing::error!(
5913 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5914 id,
5915 e
5916 );
5917 return;
5918 }
5919 };
5920
5921 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5922 log_js_error(
5923 &ctx,
5924 e,
5925 &format!("calling streaming callback {}", id),
5926 );
5927 }
5928
5929 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5930 });
5931 }
5932}
5933
5934#[cfg(test)]
5935mod tests {
5936 use super::*;
5937 use fresh_core::api::{BufferInfo, CursorInfo};
5938 use std::sync::mpsc;
5939
5940 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5942 let (tx, rx) = mpsc::channel();
5943 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5944 let services = Arc::new(TestServiceBridge::new());
5945 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5946 (backend, rx)
5947 }
5948
5949 struct TestServiceBridge {
5950 en_strings: std::sync::Mutex<HashMap<String, String>>,
5951 }
5952
5953 impl TestServiceBridge {
5954 fn new() -> Self {
5955 Self {
5956 en_strings: std::sync::Mutex::new(HashMap::new()),
5957 }
5958 }
5959 }
5960
5961 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5962 fn as_any(&self) -> &dyn std::any::Any {
5963 self
5964 }
5965 fn translate(
5966 &self,
5967 _plugin_name: &str,
5968 key: &str,
5969 _args: &HashMap<String, String>,
5970 ) -> String {
5971 self.en_strings
5972 .lock()
5973 .unwrap()
5974 .get(key)
5975 .cloned()
5976 .unwrap_or_else(|| key.to_string())
5977 }
5978 fn current_locale(&self) -> String {
5979 "en".to_string()
5980 }
5981 fn set_js_execution_state(&self, _state: String) {}
5982 fn clear_js_execution_state(&self) {}
5983 fn get_theme_schema(&self) -> serde_json::Value {
5984 serde_json::json!({})
5985 }
5986 fn get_builtin_themes(&self) -> serde_json::Value {
5987 serde_json::json!([])
5988 }
5989 fn register_command(&self, _command: fresh_core::command::Command) {}
5990 fn unregister_command(&self, _name: &str) {}
5991 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5992 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5993 fn plugins_dir(&self) -> std::path::PathBuf {
5994 std::path::PathBuf::from("/tmp/plugins")
5995 }
5996 fn config_dir(&self) -> std::path::PathBuf {
5997 std::path::PathBuf::from("/tmp/config")
5998 }
5999 fn data_dir(&self) -> std::path::PathBuf {
6000 std::path::PathBuf::from("/tmp/data")
6001 }
6002 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
6003 None
6004 }
6005 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6006 Err("not implemented in test".to_string())
6007 }
6008 fn theme_file_exists(&self, _name: &str) -> bool {
6009 false
6010 }
6011 }
6012
6013 #[test]
6014 fn test_quickjs_backend_creation() {
6015 let backend = QuickJsBackend::new();
6016 assert!(backend.is_ok());
6017 }
6018
6019 #[test]
6020 fn test_execute_simple_js() {
6021 let mut backend = QuickJsBackend::new().unwrap();
6022 let result = backend.execute_js("const x = 1 + 2;", "test.js");
6023 assert!(result.is_ok());
6024 }
6025
6026 #[test]
6027 fn test_event_handler_registration() {
6028 let backend = QuickJsBackend::new().unwrap();
6029
6030 assert!(!backend.has_handlers("test_event"));
6032
6033 backend
6035 .event_handlers
6036 .borrow_mut()
6037 .entry("test_event".to_string())
6038 .or_default()
6039 .push(PluginHandler {
6040 plugin_name: "test".to_string(),
6041 handler_name: "testHandler".to_string(),
6042 });
6043
6044 assert!(backend.has_handlers("test_event"));
6046 }
6047
6048 #[test]
6051 fn test_api_set_status() {
6052 let (mut backend, rx) = create_test_backend();
6053
6054 backend
6055 .execute_js(
6056 r#"
6057 const editor = getEditor();
6058 editor.setStatus("Hello from test");
6059 "#,
6060 "test.js",
6061 )
6062 .unwrap();
6063
6064 let cmd = rx.try_recv().unwrap();
6065 match cmd {
6066 PluginCommand::SetStatus { message } => {
6067 assert_eq!(message, "Hello from test");
6068 }
6069 _ => panic!("Expected SetStatus command, got {:?}", cmd),
6070 }
6071 }
6072
6073 #[test]
6074 fn test_api_register_command() {
6075 let (mut backend, rx) = create_test_backend();
6076
6077 backend
6078 .execute_js(
6079 r#"
6080 const editor = getEditor();
6081 globalThis.myTestHandler = function() { };
6082 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
6083 "#,
6084 "test_plugin.js",
6085 )
6086 .unwrap();
6087
6088 let cmd = rx.try_recv().unwrap();
6089 match cmd {
6090 PluginCommand::RegisterCommand { command } => {
6091 assert_eq!(command.name, "Test Command");
6092 assert_eq!(command.description, "A test command");
6093 assert_eq!(command.plugin_name, "test_plugin");
6095 }
6096 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
6097 }
6098 }
6099
6100 #[test]
6101 fn test_api_define_mode() {
6102 let (mut backend, rx) = create_test_backend();
6103
6104 backend
6105 .execute_js(
6106 r#"
6107 const editor = getEditor();
6108 editor.defineMode("test-mode", [
6109 ["a", "action_a"],
6110 ["b", "action_b"]
6111 ]);
6112 "#,
6113 "test.js",
6114 )
6115 .unwrap();
6116
6117 let cmd = rx.try_recv().unwrap();
6118 match cmd {
6119 PluginCommand::DefineMode {
6120 name,
6121 bindings,
6122 read_only,
6123 allow_text_input,
6124 inherit_normal_bindings,
6125 plugin_name,
6126 } => {
6127 assert_eq!(name, "test-mode");
6128 assert_eq!(bindings.len(), 2);
6129 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
6130 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
6131 assert!(!read_only);
6132 assert!(!allow_text_input);
6133 assert!(!inherit_normal_bindings);
6134 assert!(plugin_name.is_some());
6135 }
6136 _ => panic!("Expected DefineMode, got {:?}", cmd),
6137 }
6138 }
6139
6140 #[test]
6141 fn test_api_set_editor_mode() {
6142 let (mut backend, rx) = create_test_backend();
6143
6144 backend
6145 .execute_js(
6146 r#"
6147 const editor = getEditor();
6148 editor.setEditorMode("vi-normal");
6149 "#,
6150 "test.js",
6151 )
6152 .unwrap();
6153
6154 let cmd = rx.try_recv().unwrap();
6155 match cmd {
6156 PluginCommand::SetEditorMode { mode } => {
6157 assert_eq!(mode, Some("vi-normal".to_string()));
6158 }
6159 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
6160 }
6161 }
6162
6163 #[test]
6164 fn test_api_clear_editor_mode() {
6165 let (mut backend, rx) = create_test_backend();
6166
6167 backend
6168 .execute_js(
6169 r#"
6170 const editor = getEditor();
6171 editor.setEditorMode(null);
6172 "#,
6173 "test.js",
6174 )
6175 .unwrap();
6176
6177 let cmd = rx.try_recv().unwrap();
6178 match cmd {
6179 PluginCommand::SetEditorMode { mode } => {
6180 assert!(mode.is_none());
6181 }
6182 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
6183 }
6184 }
6185
6186 #[test]
6187 fn test_api_insert_at_cursor() {
6188 let (mut backend, rx) = create_test_backend();
6189
6190 backend
6191 .execute_js(
6192 r#"
6193 const editor = getEditor();
6194 editor.insertAtCursor("Hello, World!");
6195 "#,
6196 "test.js",
6197 )
6198 .unwrap();
6199
6200 let cmd = rx.try_recv().unwrap();
6201 match cmd {
6202 PluginCommand::InsertAtCursor { text } => {
6203 assert_eq!(text, "Hello, World!");
6204 }
6205 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
6206 }
6207 }
6208
6209 #[test]
6210 fn test_api_set_context() {
6211 let (mut backend, rx) = create_test_backend();
6212
6213 backend
6214 .execute_js(
6215 r#"
6216 const editor = getEditor();
6217 editor.setContext("myContext", true);
6218 "#,
6219 "test.js",
6220 )
6221 .unwrap();
6222
6223 let cmd = rx.try_recv().unwrap();
6224 match cmd {
6225 PluginCommand::SetContext { name, active } => {
6226 assert_eq!(name, "myContext");
6227 assert!(active);
6228 }
6229 _ => panic!("Expected SetContext, got {:?}", cmd),
6230 }
6231 }
6232
6233 #[tokio::test]
6234 async fn test_execute_action_sync_function() {
6235 let (mut backend, rx) = create_test_backend();
6236
6237 backend.registered_actions.borrow_mut().insert(
6239 "my_sync_action".to_string(),
6240 PluginHandler {
6241 plugin_name: "test".to_string(),
6242 handler_name: "my_sync_action".to_string(),
6243 },
6244 );
6245
6246 backend
6248 .execute_js(
6249 r#"
6250 const editor = getEditor();
6251 globalThis.my_sync_action = function() {
6252 editor.setStatus("sync action executed");
6253 };
6254 "#,
6255 "test.js",
6256 )
6257 .unwrap();
6258
6259 while rx.try_recv().is_ok() {}
6261
6262 backend.execute_action("my_sync_action").await.unwrap();
6264
6265 let cmd = rx.try_recv().unwrap();
6267 match cmd {
6268 PluginCommand::SetStatus { message } => {
6269 assert_eq!(message, "sync action executed");
6270 }
6271 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
6272 }
6273 }
6274
6275 #[tokio::test]
6276 async fn test_execute_action_async_function() {
6277 let (mut backend, rx) = create_test_backend();
6278
6279 backend.registered_actions.borrow_mut().insert(
6281 "my_async_action".to_string(),
6282 PluginHandler {
6283 plugin_name: "test".to_string(),
6284 handler_name: "my_async_action".to_string(),
6285 },
6286 );
6287
6288 backend
6290 .execute_js(
6291 r#"
6292 const editor = getEditor();
6293 globalThis.my_async_action = async function() {
6294 await Promise.resolve();
6295 editor.setStatus("async action executed");
6296 };
6297 "#,
6298 "test.js",
6299 )
6300 .unwrap();
6301
6302 while rx.try_recv().is_ok() {}
6304
6305 backend.execute_action("my_async_action").await.unwrap();
6307
6308 let cmd = rx.try_recv().unwrap();
6310 match cmd {
6311 PluginCommand::SetStatus { message } => {
6312 assert_eq!(message, "async action executed");
6313 }
6314 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
6315 }
6316 }
6317
6318 #[tokio::test]
6319 async fn test_execute_action_with_registered_handler() {
6320 let (mut backend, rx) = create_test_backend();
6321
6322 backend.registered_actions.borrow_mut().insert(
6324 "my_action".to_string(),
6325 PluginHandler {
6326 plugin_name: "test".to_string(),
6327 handler_name: "actual_handler_function".to_string(),
6328 },
6329 );
6330
6331 backend
6332 .execute_js(
6333 r#"
6334 const editor = getEditor();
6335 globalThis.actual_handler_function = function() {
6336 editor.setStatus("handler executed");
6337 };
6338 "#,
6339 "test.js",
6340 )
6341 .unwrap();
6342
6343 while rx.try_recv().is_ok() {}
6345
6346 backend.execute_action("my_action").await.unwrap();
6348
6349 let cmd = rx.try_recv().unwrap();
6350 match cmd {
6351 PluginCommand::SetStatus { message } => {
6352 assert_eq!(message, "handler executed");
6353 }
6354 _ => panic!("Expected SetStatus, got {:?}", cmd),
6355 }
6356 }
6357
6358 #[test]
6359 fn test_api_on_event_registration() {
6360 let (mut backend, _rx) = create_test_backend();
6361
6362 backend
6363 .execute_js(
6364 r#"
6365 const editor = getEditor();
6366 globalThis.myEventHandler = function() { };
6367 editor.on("bufferSave", "myEventHandler");
6368 "#,
6369 "test.js",
6370 )
6371 .unwrap();
6372
6373 assert!(backend.has_handlers("bufferSave"));
6374 }
6375
6376 #[test]
6377 fn test_api_off_event_unregistration() {
6378 let (mut backend, _rx) = create_test_backend();
6379
6380 backend
6381 .execute_js(
6382 r#"
6383 const editor = getEditor();
6384 globalThis.myEventHandler = function() { };
6385 editor.on("bufferSave", "myEventHandler");
6386 editor.off("bufferSave", "myEventHandler");
6387 "#,
6388 "test.js",
6389 )
6390 .unwrap();
6391
6392 assert!(!backend.has_handlers("bufferSave"));
6394 }
6395
6396 #[tokio::test]
6397 async fn test_emit_event() {
6398 let (mut backend, rx) = create_test_backend();
6399
6400 backend
6401 .execute_js(
6402 r#"
6403 const editor = getEditor();
6404 globalThis.onSaveHandler = function(data) {
6405 editor.setStatus("saved: " + JSON.stringify(data));
6406 };
6407 editor.on("bufferSave", "onSaveHandler");
6408 "#,
6409 "test.js",
6410 )
6411 .unwrap();
6412
6413 while rx.try_recv().is_ok() {}
6415
6416 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
6418 backend.emit("bufferSave", &event_data).await.unwrap();
6419
6420 let cmd = rx.try_recv().unwrap();
6421 match cmd {
6422 PluginCommand::SetStatus { message } => {
6423 assert!(message.contains("/test.txt"));
6424 }
6425 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
6426 }
6427 }
6428
6429 #[test]
6430 fn test_api_copy_to_clipboard() {
6431 let (mut backend, rx) = create_test_backend();
6432
6433 backend
6434 .execute_js(
6435 r#"
6436 const editor = getEditor();
6437 editor.copyToClipboard("clipboard text");
6438 "#,
6439 "test.js",
6440 )
6441 .unwrap();
6442
6443 let cmd = rx.try_recv().unwrap();
6444 match cmd {
6445 PluginCommand::SetClipboard { text } => {
6446 assert_eq!(text, "clipboard text");
6447 }
6448 _ => panic!("Expected SetClipboard, got {:?}", cmd),
6449 }
6450 }
6451
6452 #[test]
6453 fn test_api_open_file() {
6454 let (mut backend, rx) = create_test_backend();
6455
6456 backend
6458 .execute_js(
6459 r#"
6460 const editor = getEditor();
6461 editor.openFile("/path/to/file.txt", null, null);
6462 "#,
6463 "test.js",
6464 )
6465 .unwrap();
6466
6467 let cmd = rx.try_recv().unwrap();
6468 match cmd {
6469 PluginCommand::OpenFileAtLocation { path, line, column } => {
6470 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
6471 assert!(line.is_none());
6472 assert!(column.is_none());
6473 }
6474 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
6475 }
6476 }
6477
6478 #[test]
6479 fn test_api_delete_range() {
6480 let (mut backend, rx) = create_test_backend();
6481
6482 backend
6484 .execute_js(
6485 r#"
6486 const editor = getEditor();
6487 editor.deleteRange(0, 10, 20);
6488 "#,
6489 "test.js",
6490 )
6491 .unwrap();
6492
6493 let cmd = rx.try_recv().unwrap();
6494 match cmd {
6495 PluginCommand::DeleteRange { range, .. } => {
6496 assert_eq!(range.start, 10);
6497 assert_eq!(range.end, 20);
6498 }
6499 _ => panic!("Expected DeleteRange, got {:?}", cmd),
6500 }
6501 }
6502
6503 #[test]
6504 fn test_api_insert_text() {
6505 let (mut backend, rx) = create_test_backend();
6506
6507 backend
6509 .execute_js(
6510 r#"
6511 const editor = getEditor();
6512 editor.insertText(0, 5, "inserted");
6513 "#,
6514 "test.js",
6515 )
6516 .unwrap();
6517
6518 let cmd = rx.try_recv().unwrap();
6519 match cmd {
6520 PluginCommand::InsertText { position, text, .. } => {
6521 assert_eq!(position, 5);
6522 assert_eq!(text, "inserted");
6523 }
6524 _ => panic!("Expected InsertText, got {:?}", cmd),
6525 }
6526 }
6527
6528 #[test]
6529 fn test_api_set_buffer_cursor() {
6530 let (mut backend, rx) = create_test_backend();
6531
6532 backend
6534 .execute_js(
6535 r#"
6536 const editor = getEditor();
6537 editor.setBufferCursor(0, 100);
6538 "#,
6539 "test.js",
6540 )
6541 .unwrap();
6542
6543 let cmd = rx.try_recv().unwrap();
6544 match cmd {
6545 PluginCommand::SetBufferCursor { position, .. } => {
6546 assert_eq!(position, 100);
6547 }
6548 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
6549 }
6550 }
6551
6552 #[test]
6553 fn test_api_get_cursor_position_from_state() {
6554 let (tx, _rx) = mpsc::channel();
6555 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6556
6557 {
6559 let mut state = state_snapshot.write().unwrap();
6560 state.primary_cursor = Some(CursorInfo {
6561 position: 42,
6562 selection: None,
6563 });
6564 }
6565
6566 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6567 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6568
6569 backend
6571 .execute_js(
6572 r#"
6573 const editor = getEditor();
6574 const pos = editor.getCursorPosition();
6575 globalThis._testResult = pos;
6576 "#,
6577 "test.js",
6578 )
6579 .unwrap();
6580
6581 backend
6583 .plugin_contexts
6584 .borrow()
6585 .get("test")
6586 .unwrap()
6587 .clone()
6588 .with(|ctx| {
6589 let global = ctx.globals();
6590 let result: u32 = global.get("_testResult").unwrap();
6591 assert_eq!(result, 42);
6592 });
6593 }
6594
6595 #[test]
6596 fn test_api_path_functions() {
6597 let (mut backend, _rx) = create_test_backend();
6598
6599 #[cfg(windows)]
6602 let absolute_path = r#"C:\\foo\\bar"#;
6603 #[cfg(not(windows))]
6604 let absolute_path = "/foo/bar";
6605
6606 let js_code = format!(
6608 r#"
6609 const editor = getEditor();
6610 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
6611 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
6612 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
6613 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
6614 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
6615 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
6616 "#,
6617 absolute_path
6618 );
6619 backend.execute_js(&js_code, "test.js").unwrap();
6620
6621 backend
6622 .plugin_contexts
6623 .borrow()
6624 .get("test")
6625 .unwrap()
6626 .clone()
6627 .with(|ctx| {
6628 let global = ctx.globals();
6629 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
6630 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
6631 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
6632 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
6633 assert!(!global.get::<_, bool>("_isRelative").unwrap());
6634 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
6635 });
6636 }
6637
6638 #[test]
6646 fn test_path_join_preserves_unc_prefix() {
6647 let (mut backend, _rx) = create_test_backend();
6648 backend
6649 .execute_js(
6650 r#"
6651 const editor = getEditor();
6652 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
6653 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
6654 globalThis._posix = editor.pathJoin("/foo", "bar");
6655 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
6656 "#,
6657 "test.js",
6658 )
6659 .unwrap();
6660
6661 backend
6662 .plugin_contexts
6663 .borrow()
6664 .get("test")
6665 .unwrap()
6666 .clone()
6667 .with(|ctx| {
6668 let global = ctx.globals();
6669 assert_eq!(
6670 global.get::<_, String>("_unc").unwrap(),
6671 "//?/C:/workspace/.devcontainer/devcontainer.json",
6672 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
6673 );
6674 assert_eq!(
6675 global.get::<_, String>("_unc_fwd").unwrap(),
6676 "//?/C:/workspace/.devcontainer/devcontainer.json",
6677 "UNC prefix in forward-slash form stays as `//`",
6678 );
6679 assert_eq!(
6680 global.get::<_, String>("_posix").unwrap(),
6681 "/foo/bar",
6682 "POSIX absolute paths keep their single leading slash",
6683 );
6684 assert_eq!(
6685 global.get::<_, String>("_drive").unwrap(),
6686 "C:/foo/bar",
6687 "Windows drive-letter paths have no leading slash",
6688 );
6689 });
6690 }
6691
6692 #[test]
6693 fn test_file_uri_to_path_and_back() {
6694 let (mut backend, _rx) = create_test_backend();
6695
6696 #[cfg(not(windows))]
6698 let js_code = r#"
6699 const editor = getEditor();
6700 // Basic file URI to path
6701 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
6702 // Percent-encoded characters
6703 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
6704 // Invalid URI returns empty string
6705 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6706 // Path to file URI
6707 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
6708 // Round-trip
6709 globalThis._roundtrip = editor.fileUriToPath(
6710 editor.pathToFileUri("/home/user/file.txt")
6711 );
6712 "#;
6713
6714 #[cfg(windows)]
6715 let js_code = r#"
6716 const editor = getEditor();
6717 // Windows URI with encoded colon (the bug from issue #1071)
6718 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
6719 // Windows URI with normal colon
6720 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
6721 // Invalid URI returns empty string
6722 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6723 // Path to file URI
6724 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
6725 // Round-trip
6726 globalThis._roundtrip = editor.fileUriToPath(
6727 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
6728 );
6729 "#;
6730
6731 backend.execute_js(js_code, "test.js").unwrap();
6732
6733 backend
6734 .plugin_contexts
6735 .borrow()
6736 .get("test")
6737 .unwrap()
6738 .clone()
6739 .with(|ctx| {
6740 let global = ctx.globals();
6741
6742 #[cfg(not(windows))]
6743 {
6744 assert_eq!(
6745 global.get::<_, String>("_path1").unwrap(),
6746 "/home/user/file.txt"
6747 );
6748 assert_eq!(
6749 global.get::<_, String>("_path2").unwrap(),
6750 "/home/user/my file.txt"
6751 );
6752 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6753 assert_eq!(
6754 global.get::<_, String>("_uri1").unwrap(),
6755 "file:///home/user/file.txt"
6756 );
6757 assert_eq!(
6758 global.get::<_, String>("_roundtrip").unwrap(),
6759 "/home/user/file.txt"
6760 );
6761 }
6762
6763 #[cfg(windows)]
6764 {
6765 assert_eq!(
6767 global.get::<_, String>("_path1").unwrap(),
6768 "C:\\Users\\admin\\Repos\\file.cs"
6769 );
6770 assert_eq!(
6771 global.get::<_, String>("_path2").unwrap(),
6772 "C:\\Users\\admin\\Repos\\file.cs"
6773 );
6774 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6775 assert_eq!(
6776 global.get::<_, String>("_uri1").unwrap(),
6777 "file:///C:/Users/admin/Repos/file.cs"
6778 );
6779 assert_eq!(
6780 global.get::<_, String>("_roundtrip").unwrap(),
6781 "C:\\Users\\admin\\Repos\\file.cs"
6782 );
6783 }
6784 });
6785 }
6786
6787 #[test]
6788 fn test_typescript_transpilation() {
6789 use fresh_parser_js::transpile_typescript;
6790
6791 let (mut backend, rx) = create_test_backend();
6792
6793 let ts_code = r#"
6795 const editor = getEditor();
6796 function greet(name: string): string {
6797 return "Hello, " + name;
6798 }
6799 editor.setStatus(greet("TypeScript"));
6800 "#;
6801
6802 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6804
6805 backend.execute_js(&js_code, "test.js").unwrap();
6807
6808 let cmd = rx.try_recv().unwrap();
6809 match cmd {
6810 PluginCommand::SetStatus { message } => {
6811 assert_eq!(message, "Hello, TypeScript");
6812 }
6813 _ => panic!("Expected SetStatus, got {:?}", cmd),
6814 }
6815 }
6816
6817 #[test]
6818 fn test_api_get_buffer_text_sends_command() {
6819 let (mut backend, rx) = create_test_backend();
6820
6821 backend
6823 .execute_js(
6824 r#"
6825 const editor = getEditor();
6826 // Store the promise for later
6827 globalThis._textPromise = editor.getBufferText(0, 10, 20);
6828 "#,
6829 "test.js",
6830 )
6831 .unwrap();
6832
6833 let cmd = rx.try_recv().unwrap();
6835 match cmd {
6836 PluginCommand::GetBufferText {
6837 buffer_id,
6838 start,
6839 end,
6840 request_id,
6841 } => {
6842 assert_eq!(buffer_id.0, 0);
6843 assert_eq!(start, 10);
6844 assert_eq!(end, 20);
6845 assert!(request_id > 0); }
6847 _ => panic!("Expected GetBufferText, got {:?}", cmd),
6848 }
6849 }
6850
6851 #[test]
6852 fn test_api_get_buffer_text_resolves_callback() {
6853 let (mut backend, rx) = create_test_backend();
6854
6855 backend
6857 .execute_js(
6858 r#"
6859 const editor = getEditor();
6860 globalThis._resolvedText = null;
6861 editor.getBufferText(0, 0, 100).then(text => {
6862 globalThis._resolvedText = text;
6863 });
6864 "#,
6865 "test.js",
6866 )
6867 .unwrap();
6868
6869 let request_id = match rx.try_recv().unwrap() {
6871 PluginCommand::GetBufferText { request_id, .. } => request_id,
6872 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6873 };
6874
6875 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6877
6878 backend
6880 .plugin_contexts
6881 .borrow()
6882 .get("test")
6883 .unwrap()
6884 .clone()
6885 .with(|ctx| {
6886 run_pending_jobs_checked(&ctx, "test async getText");
6887 });
6888
6889 backend
6891 .plugin_contexts
6892 .borrow()
6893 .get("test")
6894 .unwrap()
6895 .clone()
6896 .with(|ctx| {
6897 let global = ctx.globals();
6898 let result: String = global.get("_resolvedText").unwrap();
6899 assert_eq!(result, "hello world");
6900 });
6901 }
6902
6903 #[test]
6904 fn test_plugin_translation() {
6905 let (mut backend, _rx) = create_test_backend();
6906
6907 backend
6909 .execute_js(
6910 r#"
6911 const editor = getEditor();
6912 globalThis._translated = editor.t("test.key");
6913 "#,
6914 "test.js",
6915 )
6916 .unwrap();
6917
6918 backend
6919 .plugin_contexts
6920 .borrow()
6921 .get("test")
6922 .unwrap()
6923 .clone()
6924 .with(|ctx| {
6925 let global = ctx.globals();
6926 let result: String = global.get("_translated").unwrap();
6928 assert_eq!(result, "test.key");
6929 });
6930 }
6931
6932 #[test]
6933 fn test_plugin_translation_with_registered_strings() {
6934 let (mut backend, _rx) = create_test_backend();
6935
6936 let mut en_strings = std::collections::HashMap::new();
6938 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6939 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6940
6941 let mut strings = std::collections::HashMap::new();
6942 strings.insert("en".to_string(), en_strings);
6943
6944 if let Some(bridge) = backend
6946 .services
6947 .as_any()
6948 .downcast_ref::<TestServiceBridge>()
6949 {
6950 let mut en = bridge.en_strings.lock().unwrap();
6951 en.insert("greeting".to_string(), "Hello, World!".to_string());
6952 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6953 }
6954
6955 backend
6957 .execute_js(
6958 r#"
6959 const editor = getEditor();
6960 globalThis._greeting = editor.t("greeting");
6961 globalThis._prompt = editor.t("prompt.find_file");
6962 globalThis._missing = editor.t("nonexistent.key");
6963 "#,
6964 "test.js",
6965 )
6966 .unwrap();
6967
6968 backend
6969 .plugin_contexts
6970 .borrow()
6971 .get("test")
6972 .unwrap()
6973 .clone()
6974 .with(|ctx| {
6975 let global = ctx.globals();
6976 let greeting: String = global.get("_greeting").unwrap();
6977 assert_eq!(greeting, "Hello, World!");
6978
6979 let prompt: String = global.get("_prompt").unwrap();
6980 assert_eq!(prompt, "Find file: ");
6981
6982 let missing: String = global.get("_missing").unwrap();
6984 assert_eq!(missing, "nonexistent.key");
6985 });
6986 }
6987
6988 #[test]
6991 fn test_api_set_line_indicator() {
6992 let (mut backend, rx) = create_test_backend();
6993
6994 backend
6995 .execute_js(
6996 r#"
6997 const editor = getEditor();
6998 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6999 "#,
7000 "test.js",
7001 )
7002 .unwrap();
7003
7004 let cmd = rx.try_recv().unwrap();
7005 match cmd {
7006 PluginCommand::SetLineIndicator {
7007 buffer_id,
7008 line,
7009 namespace,
7010 symbol,
7011 color,
7012 priority,
7013 } => {
7014 assert_eq!(buffer_id.0, 1);
7015 assert_eq!(line, 5);
7016 assert_eq!(namespace, "test-ns");
7017 assert_eq!(symbol, "●");
7018 assert_eq!(color, (255, 0, 0));
7019 assert_eq!(priority, 10);
7020 }
7021 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
7022 }
7023 }
7024
7025 #[test]
7026 fn test_api_clear_line_indicators() {
7027 let (mut backend, rx) = create_test_backend();
7028
7029 backend
7030 .execute_js(
7031 r#"
7032 const editor = getEditor();
7033 editor.clearLineIndicators(1, "test-ns");
7034 "#,
7035 "test.js",
7036 )
7037 .unwrap();
7038
7039 let cmd = rx.try_recv().unwrap();
7040 match cmd {
7041 PluginCommand::ClearLineIndicators {
7042 buffer_id,
7043 namespace,
7044 } => {
7045 assert_eq!(buffer_id.0, 1);
7046 assert_eq!(namespace, "test-ns");
7047 }
7048 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
7049 }
7050 }
7051
7052 #[test]
7055 fn test_api_create_virtual_buffer_sends_command() {
7056 let (mut backend, rx) = create_test_backend();
7057
7058 backend
7059 .execute_js(
7060 r#"
7061 const editor = getEditor();
7062 editor.createVirtualBuffer({
7063 name: "*Test Buffer*",
7064 mode: "test-mode",
7065 readOnly: true,
7066 entries: [
7067 { text: "Line 1\n", properties: { type: "header" } },
7068 { text: "Line 2\n", properties: { type: "content" } }
7069 ],
7070 showLineNumbers: false,
7071 showCursors: true,
7072 editingDisabled: true
7073 });
7074 "#,
7075 "test.js",
7076 )
7077 .unwrap();
7078
7079 let cmd = rx.try_recv().unwrap();
7080 match cmd {
7081 PluginCommand::CreateVirtualBufferWithContent {
7082 name,
7083 mode,
7084 read_only,
7085 entries,
7086 show_line_numbers,
7087 show_cursors,
7088 editing_disabled,
7089 ..
7090 } => {
7091 assert_eq!(name, "*Test Buffer*");
7092 assert_eq!(mode, "test-mode");
7093 assert!(read_only);
7094 assert_eq!(entries.len(), 2);
7095 assert_eq!(entries[0].text, "Line 1\n");
7096 assert!(!show_line_numbers);
7097 assert!(show_cursors);
7098 assert!(editing_disabled);
7099 }
7100 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
7101 }
7102 }
7103
7104 #[test]
7105 fn test_api_set_virtual_buffer_content() {
7106 let (mut backend, rx) = create_test_backend();
7107
7108 backend
7109 .execute_js(
7110 r#"
7111 const editor = getEditor();
7112 editor.setVirtualBufferContent(5, [
7113 { text: "New content\n", properties: { type: "updated" } }
7114 ]);
7115 "#,
7116 "test.js",
7117 )
7118 .unwrap();
7119
7120 let cmd = rx.try_recv().unwrap();
7121 match cmd {
7122 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
7123 assert_eq!(buffer_id.0, 5);
7124 assert_eq!(entries.len(), 1);
7125 assert_eq!(entries[0].text, "New content\n");
7126 }
7127 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
7128 }
7129 }
7130
7131 #[test]
7134 fn test_api_add_overlay() {
7135 let (mut backend, rx) = create_test_backend();
7136
7137 backend
7138 .execute_js(
7139 r#"
7140 const editor = getEditor();
7141 editor.addOverlay(1, "highlight", 10, 20, {
7142 fg: [255, 128, 0],
7143 bg: [50, 50, 50],
7144 bold: true,
7145 });
7146 "#,
7147 "test.js",
7148 )
7149 .unwrap();
7150
7151 let cmd = rx.try_recv().unwrap();
7152 match cmd {
7153 PluginCommand::AddOverlay {
7154 buffer_id,
7155 namespace,
7156 range,
7157 options,
7158 } => {
7159 use fresh_core::api::OverlayColorSpec;
7160 assert_eq!(buffer_id.0, 1);
7161 assert!(namespace.is_some());
7162 assert_eq!(namespace.unwrap().as_str(), "highlight");
7163 assert_eq!(range, 10..20);
7164 assert!(matches!(
7165 options.fg,
7166 Some(OverlayColorSpec::Rgb(255, 128, 0))
7167 ));
7168 assert!(matches!(
7169 options.bg,
7170 Some(OverlayColorSpec::Rgb(50, 50, 50))
7171 ));
7172 assert!(!options.underline);
7173 assert!(options.bold);
7174 assert!(!options.italic);
7175 assert!(!options.extend_to_line_end);
7176 }
7177 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7178 }
7179 }
7180
7181 #[test]
7182 fn test_api_add_overlay_with_theme_keys() {
7183 let (mut backend, rx) = create_test_backend();
7184
7185 backend
7186 .execute_js(
7187 r#"
7188 const editor = getEditor();
7189 // Test with theme keys for colors
7190 editor.addOverlay(1, "themed", 0, 10, {
7191 fg: "ui.status_bar_fg",
7192 bg: "editor.selection_bg",
7193 });
7194 "#,
7195 "test.js",
7196 )
7197 .unwrap();
7198
7199 let cmd = rx.try_recv().unwrap();
7200 match cmd {
7201 PluginCommand::AddOverlay {
7202 buffer_id,
7203 namespace,
7204 range,
7205 options,
7206 } => {
7207 use fresh_core::api::OverlayColorSpec;
7208 assert_eq!(buffer_id.0, 1);
7209 assert!(namespace.is_some());
7210 assert_eq!(namespace.unwrap().as_str(), "themed");
7211 assert_eq!(range, 0..10);
7212 assert!(matches!(
7213 &options.fg,
7214 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
7215 ));
7216 assert!(matches!(
7217 &options.bg,
7218 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
7219 ));
7220 assert!(!options.underline);
7221 assert!(!options.bold);
7222 assert!(!options.italic);
7223 assert!(!options.extend_to_line_end);
7224 }
7225 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7226 }
7227 }
7228
7229 #[test]
7230 fn test_api_clear_namespace() {
7231 let (mut backend, rx) = create_test_backend();
7232
7233 backend
7234 .execute_js(
7235 r#"
7236 const editor = getEditor();
7237 editor.clearNamespace(1, "highlight");
7238 "#,
7239 "test.js",
7240 )
7241 .unwrap();
7242
7243 let cmd = rx.try_recv().unwrap();
7244 match cmd {
7245 PluginCommand::ClearNamespace {
7246 buffer_id,
7247 namespace,
7248 } => {
7249 assert_eq!(buffer_id.0, 1);
7250 assert_eq!(namespace.as_str(), "highlight");
7251 }
7252 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
7253 }
7254 }
7255
7256 #[test]
7259 fn test_api_get_theme_schema() {
7260 let (mut backend, _rx) = create_test_backend();
7261
7262 backend
7263 .execute_js(
7264 r#"
7265 const editor = getEditor();
7266 const schema = editor.getThemeSchema();
7267 globalThis._isObject = typeof schema === 'object' && schema !== null;
7268 "#,
7269 "test.js",
7270 )
7271 .unwrap();
7272
7273 backend
7274 .plugin_contexts
7275 .borrow()
7276 .get("test")
7277 .unwrap()
7278 .clone()
7279 .with(|ctx| {
7280 let global = ctx.globals();
7281 let is_object: bool = global.get("_isObject").unwrap();
7282 assert!(is_object);
7284 });
7285 }
7286
7287 #[test]
7288 fn test_api_get_builtin_themes() {
7289 let (mut backend, _rx) = create_test_backend();
7290
7291 backend
7292 .execute_js(
7293 r#"
7294 const editor = getEditor();
7295 const themes = editor.getBuiltinThemes();
7296 globalThis._isObject = typeof themes === 'object' && themes !== null;
7297 "#,
7298 "test.js",
7299 )
7300 .unwrap();
7301
7302 backend
7303 .plugin_contexts
7304 .borrow()
7305 .get("test")
7306 .unwrap()
7307 .clone()
7308 .with(|ctx| {
7309 let global = ctx.globals();
7310 let is_object: bool = global.get("_isObject").unwrap();
7311 assert!(is_object);
7313 });
7314 }
7315
7316 #[test]
7317 fn test_api_apply_theme() {
7318 let (mut backend, rx) = create_test_backend();
7319
7320 backend
7321 .execute_js(
7322 r#"
7323 const editor = getEditor();
7324 editor.applyTheme("dark");
7325 "#,
7326 "test.js",
7327 )
7328 .unwrap();
7329
7330 let cmd = rx.try_recv().unwrap();
7331 match cmd {
7332 PluginCommand::ApplyTheme { theme_name } => {
7333 assert_eq!(theme_name, "dark");
7334 }
7335 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
7336 }
7337 }
7338
7339 #[test]
7340 fn test_api_override_theme_colors_round_trip() {
7341 let (mut backend, rx) = create_test_backend();
7344
7345 backend
7346 .execute_js(
7347 r#"
7348 const editor = getEditor();
7349 editor.overrideThemeColors({
7350 "editor.bg": [10, 20, 30],
7351 "editor.fg": [220, 221, 222],
7352 });
7353 "#,
7354 "test.js",
7355 )
7356 .unwrap();
7357
7358 let cmd = rx.try_recv().unwrap();
7359 match cmd {
7360 PluginCommand::OverrideThemeColors { overrides } => {
7361 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
7362 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
7363 assert_eq!(overrides.len(), 2);
7364 }
7365 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
7366 }
7367 }
7368
7369 #[test]
7370 fn test_api_override_theme_colors_clamps_out_of_range() {
7371 let (mut backend, rx) = create_test_backend();
7372
7373 backend
7374 .execute_js(
7375 r#"
7376 const editor = getEditor();
7377 editor.overrideThemeColors({
7378 "editor.bg": [-5, 300, 128],
7379 });
7380 "#,
7381 "test.js",
7382 )
7383 .unwrap();
7384
7385 match rx.try_recv().unwrap() {
7386 PluginCommand::OverrideThemeColors { overrides } => {
7387 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
7388 }
7389 other => panic!("Expected OverrideThemeColors, got {other:?}"),
7390 }
7391 }
7392
7393 #[test]
7394 fn test_api_override_theme_colors_drops_malformed_entries() {
7395 let (mut backend, rx) = create_test_backend();
7398
7399 backend
7400 .execute_js(
7401 r#"
7402 const editor = getEditor();
7403 editor.overrideThemeColors({
7404 "editor.bg": [1, 2, 3],
7405 "not_an_array": "oops",
7406 "wrong_length": [1, 2],
7407 "floats_are_fine": [10.7, 20.2, 30.9],
7408 });
7409 "#,
7410 "test.js",
7411 )
7412 .unwrap();
7413
7414 match rx.try_recv().unwrap() {
7415 PluginCommand::OverrideThemeColors { overrides } => {
7416 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
7417 assert!(!overrides.contains_key("not_an_array"));
7418 assert!(!overrides.contains_key("wrong_length"));
7419 assert_eq!(
7421 overrides.get("floats_are_fine").copied(),
7422 Some([10, 20, 30])
7423 );
7424 }
7425 other => panic!("Expected OverrideThemeColors, got {other:?}"),
7426 }
7427 }
7428
7429 #[test]
7430 fn test_api_get_theme_data_missing() {
7431 let (mut backend, _rx) = create_test_backend();
7432
7433 backend
7434 .execute_js(
7435 r#"
7436 const editor = getEditor();
7437 const data = editor.getThemeData("nonexistent");
7438 globalThis._isNull = data === null;
7439 "#,
7440 "test.js",
7441 )
7442 .unwrap();
7443
7444 backend
7445 .plugin_contexts
7446 .borrow()
7447 .get("test")
7448 .unwrap()
7449 .clone()
7450 .with(|ctx| {
7451 let global = ctx.globals();
7452 let is_null: bool = global.get("_isNull").unwrap();
7453 assert!(is_null);
7455 });
7456 }
7457
7458 #[test]
7459 fn test_api_get_theme_data_present() {
7460 let (tx, _rx) = mpsc::channel();
7462 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7463 let services = Arc::new(ThemeCacheTestBridge {
7464 inner: TestServiceBridge::new(),
7465 });
7466 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7467
7468 backend
7469 .execute_js(
7470 r#"
7471 const editor = getEditor();
7472 const data = editor.getThemeData("test-theme");
7473 globalThis._hasData = data !== null && typeof data === 'object';
7474 globalThis._name = data ? data.name : null;
7475 "#,
7476 "test.js",
7477 )
7478 .unwrap();
7479
7480 backend
7481 .plugin_contexts
7482 .borrow()
7483 .get("test")
7484 .unwrap()
7485 .clone()
7486 .with(|ctx| {
7487 let global = ctx.globals();
7488 let has_data: bool = global.get("_hasData").unwrap();
7489 assert!(has_data, "getThemeData should return theme object");
7490 let name: String = global.get("_name").unwrap();
7491 assert_eq!(name, "test-theme");
7492 });
7493 }
7494
7495 #[test]
7496 fn test_api_theme_file_exists() {
7497 let (mut backend, _rx) = create_test_backend();
7498
7499 backend
7500 .execute_js(
7501 r#"
7502 const editor = getEditor();
7503 globalThis._exists = editor.themeFileExists("anything");
7504 "#,
7505 "test.js",
7506 )
7507 .unwrap();
7508
7509 backend
7510 .plugin_contexts
7511 .borrow()
7512 .get("test")
7513 .unwrap()
7514 .clone()
7515 .with(|ctx| {
7516 let global = ctx.globals();
7517 let exists: bool = global.get("_exists").unwrap();
7518 assert!(!exists);
7520 });
7521 }
7522
7523 #[test]
7524 fn test_api_save_theme_file_error() {
7525 let (mut backend, _rx) = create_test_backend();
7526
7527 backend
7528 .execute_js(
7529 r#"
7530 const editor = getEditor();
7531 let threw = false;
7532 try {
7533 editor.saveThemeFile("test", "{}");
7534 } catch (e) {
7535 threw = true;
7536 }
7537 globalThis._threw = threw;
7538 "#,
7539 "test.js",
7540 )
7541 .unwrap();
7542
7543 backend
7544 .plugin_contexts
7545 .borrow()
7546 .get("test")
7547 .unwrap()
7548 .clone()
7549 .with(|ctx| {
7550 let global = ctx.globals();
7551 let threw: bool = global.get("_threw").unwrap();
7552 assert!(threw);
7554 });
7555 }
7556
7557 struct ThemeCacheTestBridge {
7559 inner: TestServiceBridge,
7560 }
7561
7562 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
7563 fn as_any(&self) -> &dyn std::any::Any {
7564 self
7565 }
7566 fn translate(
7567 &self,
7568 plugin_name: &str,
7569 key: &str,
7570 args: &HashMap<String, String>,
7571 ) -> String {
7572 self.inner.translate(plugin_name, key, args)
7573 }
7574 fn current_locale(&self) -> String {
7575 self.inner.current_locale()
7576 }
7577 fn set_js_execution_state(&self, state: String) {
7578 self.inner.set_js_execution_state(state);
7579 }
7580 fn clear_js_execution_state(&self) {
7581 self.inner.clear_js_execution_state();
7582 }
7583 fn get_theme_schema(&self) -> serde_json::Value {
7584 self.inner.get_theme_schema()
7585 }
7586 fn get_builtin_themes(&self) -> serde_json::Value {
7587 self.inner.get_builtin_themes()
7588 }
7589 fn register_command(&self, command: fresh_core::command::Command) {
7590 self.inner.register_command(command);
7591 }
7592 fn unregister_command(&self, name: &str) {
7593 self.inner.unregister_command(name);
7594 }
7595 fn unregister_commands_by_prefix(&self, prefix: &str) {
7596 self.inner.unregister_commands_by_prefix(prefix);
7597 }
7598 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
7599 self.inner.unregister_commands_by_plugin(plugin_name);
7600 }
7601 fn plugins_dir(&self) -> std::path::PathBuf {
7602 self.inner.plugins_dir()
7603 }
7604 fn config_dir(&self) -> std::path::PathBuf {
7605 self.inner.config_dir()
7606 }
7607 fn data_dir(&self) -> std::path::PathBuf {
7608 self.inner.data_dir()
7609 }
7610 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
7611 if name == "test-theme" {
7612 Some(serde_json::json!({
7613 "name": "test-theme",
7614 "editor": {},
7615 "ui": {},
7616 "syntax": {}
7617 }))
7618 } else {
7619 None
7620 }
7621 }
7622 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7623 Err("test bridge does not support save".to_string())
7624 }
7625 fn theme_file_exists(&self, name: &str) -> bool {
7626 name == "test-theme"
7627 }
7628 }
7629
7630 #[test]
7633 fn test_api_close_buffer() {
7634 let (mut backend, rx) = create_test_backend();
7635
7636 backend
7637 .execute_js(
7638 r#"
7639 const editor = getEditor();
7640 editor.closeBuffer(3);
7641 "#,
7642 "test.js",
7643 )
7644 .unwrap();
7645
7646 let cmd = rx.try_recv().unwrap();
7647 match cmd {
7648 PluginCommand::CloseBuffer { buffer_id } => {
7649 assert_eq!(buffer_id.0, 3);
7650 }
7651 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
7652 }
7653 }
7654
7655 #[test]
7656 fn test_api_focus_split() {
7657 let (mut backend, rx) = create_test_backend();
7658
7659 backend
7660 .execute_js(
7661 r#"
7662 const editor = getEditor();
7663 editor.focusSplit(2);
7664 "#,
7665 "test.js",
7666 )
7667 .unwrap();
7668
7669 let cmd = rx.try_recv().unwrap();
7670 match cmd {
7671 PluginCommand::FocusSplit { split_id } => {
7672 assert_eq!(split_id.0, 2);
7673 }
7674 _ => panic!("Expected FocusSplit, got {:?}", cmd),
7675 }
7676 }
7677
7678 #[test]
7679 fn test_api_list_buffers() {
7680 let (tx, _rx) = mpsc::channel();
7681 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7682
7683 {
7685 let mut state = state_snapshot.write().unwrap();
7686 state.buffers.insert(
7687 BufferId(0),
7688 BufferInfo {
7689 id: BufferId(0),
7690 path: Some(PathBuf::from("/test1.txt")),
7691 modified: false,
7692 length: 100,
7693 is_virtual: false,
7694 view_mode: "source".to_string(),
7695 is_composing_in_any_split: false,
7696 compose_width: None,
7697 language: "text".to_string(),
7698 is_preview: false,
7699 splits: Vec::new(),
7700 },
7701 );
7702 state.buffers.insert(
7703 BufferId(1),
7704 BufferInfo {
7705 id: BufferId(1),
7706 path: Some(PathBuf::from("/test2.txt")),
7707 modified: true,
7708 length: 200,
7709 is_virtual: false,
7710 view_mode: "source".to_string(),
7711 is_composing_in_any_split: false,
7712 compose_width: None,
7713 language: "text".to_string(),
7714 is_preview: false,
7715 splits: Vec::new(),
7716 },
7717 );
7718 }
7719
7720 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7721 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7722
7723 backend
7724 .execute_js(
7725 r#"
7726 const editor = getEditor();
7727 const buffers = editor.listBuffers();
7728 globalThis._isArray = Array.isArray(buffers);
7729 globalThis._length = buffers.length;
7730 "#,
7731 "test.js",
7732 )
7733 .unwrap();
7734
7735 backend
7736 .plugin_contexts
7737 .borrow()
7738 .get("test")
7739 .unwrap()
7740 .clone()
7741 .with(|ctx| {
7742 let global = ctx.globals();
7743 let is_array: bool = global.get("_isArray").unwrap();
7744 let length: u32 = global.get("_length").unwrap();
7745 assert!(is_array);
7746 assert_eq!(length, 2);
7747 });
7748 }
7749
7750 #[test]
7753 fn test_api_start_prompt() {
7754 let (mut backend, rx) = create_test_backend();
7755
7756 backend
7757 .execute_js(
7758 r#"
7759 const editor = getEditor();
7760 editor.startPrompt("Enter value:", "test-prompt");
7761 "#,
7762 "test.js",
7763 )
7764 .unwrap();
7765
7766 let cmd = rx.try_recv().unwrap();
7767 match cmd {
7768 PluginCommand::StartPrompt { label, prompt_type } => {
7769 assert_eq!(label, "Enter value:");
7770 assert_eq!(prompt_type, "test-prompt");
7771 }
7772 _ => panic!("Expected StartPrompt, got {:?}", cmd),
7773 }
7774 }
7775
7776 #[test]
7777 fn test_api_start_prompt_with_initial() {
7778 let (mut backend, rx) = create_test_backend();
7779
7780 backend
7781 .execute_js(
7782 r#"
7783 const editor = getEditor();
7784 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
7785 "#,
7786 "test.js",
7787 )
7788 .unwrap();
7789
7790 let cmd = rx.try_recv().unwrap();
7791 match cmd {
7792 PluginCommand::StartPromptWithInitial {
7793 label,
7794 prompt_type,
7795 initial_value,
7796 } => {
7797 assert_eq!(label, "Enter value:");
7798 assert_eq!(prompt_type, "test-prompt");
7799 assert_eq!(initial_value, "default");
7800 }
7801 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
7802 }
7803 }
7804
7805 #[test]
7806 fn test_api_set_prompt_suggestions() {
7807 let (mut backend, rx) = create_test_backend();
7808
7809 backend
7810 .execute_js(
7811 r#"
7812 const editor = getEditor();
7813 editor.setPromptSuggestions([
7814 { text: "Option 1", value: "opt1" },
7815 { text: "Option 2", value: "opt2" }
7816 ]);
7817 "#,
7818 "test.js",
7819 )
7820 .unwrap();
7821
7822 let cmd = rx.try_recv().unwrap();
7823 match cmd {
7824 PluginCommand::SetPromptSuggestions { suggestions } => {
7825 assert_eq!(suggestions.len(), 2);
7826 assert_eq!(suggestions[0].text, "Option 1");
7827 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
7828 }
7829 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
7830 }
7831 }
7832
7833 #[test]
7836 fn test_api_get_active_buffer_id() {
7837 let (tx, _rx) = mpsc::channel();
7838 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7839
7840 {
7841 let mut state = state_snapshot.write().unwrap();
7842 state.active_buffer_id = BufferId(42);
7843 }
7844
7845 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7846 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7847
7848 backend
7849 .execute_js(
7850 r#"
7851 const editor = getEditor();
7852 globalThis._activeId = editor.getActiveBufferId();
7853 "#,
7854 "test.js",
7855 )
7856 .unwrap();
7857
7858 backend
7859 .plugin_contexts
7860 .borrow()
7861 .get("test")
7862 .unwrap()
7863 .clone()
7864 .with(|ctx| {
7865 let global = ctx.globals();
7866 let result: u32 = global.get("_activeId").unwrap();
7867 assert_eq!(result, 42);
7868 });
7869 }
7870
7871 #[test]
7872 fn test_api_get_active_split_id() {
7873 let (tx, _rx) = mpsc::channel();
7874 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7875
7876 {
7877 let mut state = state_snapshot.write().unwrap();
7878 state.active_split_id = 7;
7879 }
7880
7881 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7882 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7883
7884 backend
7885 .execute_js(
7886 r#"
7887 const editor = getEditor();
7888 globalThis._splitId = editor.getActiveSplitId();
7889 "#,
7890 "test.js",
7891 )
7892 .unwrap();
7893
7894 backend
7895 .plugin_contexts
7896 .borrow()
7897 .get("test")
7898 .unwrap()
7899 .clone()
7900 .with(|ctx| {
7901 let global = ctx.globals();
7902 let result: u32 = global.get("_splitId").unwrap();
7903 assert_eq!(result, 7);
7904 });
7905 }
7906
7907 #[test]
7910 fn test_api_file_exists() {
7911 let (mut backend, _rx) = create_test_backend();
7912
7913 backend
7914 .execute_js(
7915 r#"
7916 const editor = getEditor();
7917 // Test with a path that definitely exists
7918 globalThis._exists = editor.fileExists("/");
7919 "#,
7920 "test.js",
7921 )
7922 .unwrap();
7923
7924 backend
7925 .plugin_contexts
7926 .borrow()
7927 .get("test")
7928 .unwrap()
7929 .clone()
7930 .with(|ctx| {
7931 let global = ctx.globals();
7932 let result: bool = global.get("_exists").unwrap();
7933 assert!(result);
7934 });
7935 }
7936
7937 #[test]
7938 fn test_api_parse_jsonc() {
7939 let (mut backend, _rx) = create_test_backend();
7940
7941 backend
7942 .execute_js(
7943 r#"
7944 const editor = getEditor();
7945 // Comments, trailing commas, and nested structures should all parse.
7946 const parsed = editor.parseJsonc(`{
7947 // name of the container
7948 "name": "test",
7949 "features": {
7950 "docker-in-docker": {},
7951 },
7952 /* forwarded port list */
7953 "forwardPorts": [3000, 8080,],
7954 }`);
7955 globalThis._name = parsed.name;
7956 globalThis._featureCount = Object.keys(parsed.features).length;
7957 globalThis._portCount = parsed.forwardPorts.length;
7958
7959 // Invalid JSONC should throw.
7960 try {
7961 editor.parseJsonc("{ broken");
7962 globalThis._threw = false;
7963 } catch (_e) {
7964 globalThis._threw = true;
7965 }
7966 "#,
7967 "test.js",
7968 )
7969 .unwrap();
7970
7971 backend
7972 .plugin_contexts
7973 .borrow()
7974 .get("test")
7975 .unwrap()
7976 .clone()
7977 .with(|ctx| {
7978 let global = ctx.globals();
7979 let name: String = global.get("_name").unwrap();
7980 let feature_count: u32 = global.get("_featureCount").unwrap();
7981 let port_count: u32 = global.get("_portCount").unwrap();
7982 let threw: bool = global.get("_threw").unwrap();
7983 assert_eq!(name, "test");
7984 assert_eq!(feature_count, 1);
7985 assert_eq!(port_count, 2);
7986 assert!(threw, "Invalid JSONC should throw");
7987 });
7988 }
7989
7990 #[test]
7991 fn test_api_get_cwd() {
7992 let (mut backend, _rx) = create_test_backend();
7993
7994 backend
7995 .execute_js(
7996 r#"
7997 const editor = getEditor();
7998 globalThis._cwd = editor.getCwd();
7999 "#,
8000 "test.js",
8001 )
8002 .unwrap();
8003
8004 backend
8005 .plugin_contexts
8006 .borrow()
8007 .get("test")
8008 .unwrap()
8009 .clone()
8010 .with(|ctx| {
8011 let global = ctx.globals();
8012 let result: String = global.get("_cwd").unwrap();
8013 assert!(!result.is_empty());
8015 });
8016 }
8017
8018 #[test]
8019 fn test_api_get_env() {
8020 let (mut backend, _rx) = create_test_backend();
8021
8022 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
8024
8025 backend
8026 .execute_js(
8027 r#"
8028 const editor = getEditor();
8029 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
8030 "#,
8031 "test.js",
8032 )
8033 .unwrap();
8034
8035 backend
8036 .plugin_contexts
8037 .borrow()
8038 .get("test")
8039 .unwrap()
8040 .clone()
8041 .with(|ctx| {
8042 let global = ctx.globals();
8043 let result: Option<String> = global.get("_envVal").unwrap();
8044 assert_eq!(result, Some("test_value".to_string()));
8045 });
8046
8047 std::env::remove_var("TEST_PLUGIN_VAR");
8048 }
8049
8050 #[test]
8051 fn test_api_get_config() {
8052 let (mut backend, _rx) = create_test_backend();
8053
8054 backend
8055 .execute_js(
8056 r#"
8057 const editor = getEditor();
8058 const config = editor.getConfig();
8059 globalThis._isObject = typeof config === 'object';
8060 "#,
8061 "test.js",
8062 )
8063 .unwrap();
8064
8065 backend
8066 .plugin_contexts
8067 .borrow()
8068 .get("test")
8069 .unwrap()
8070 .clone()
8071 .with(|ctx| {
8072 let global = ctx.globals();
8073 let is_object: bool = global.get("_isObject").unwrap();
8074 assert!(is_object);
8076 });
8077 }
8078
8079 #[test]
8080 fn test_api_get_themes_dir() {
8081 let (mut backend, _rx) = create_test_backend();
8082
8083 backend
8084 .execute_js(
8085 r#"
8086 const editor = getEditor();
8087 globalThis._themesDir = editor.getThemesDir();
8088 "#,
8089 "test.js",
8090 )
8091 .unwrap();
8092
8093 backend
8094 .plugin_contexts
8095 .borrow()
8096 .get("test")
8097 .unwrap()
8098 .clone()
8099 .with(|ctx| {
8100 let global = ctx.globals();
8101 let result: String = global.get("_themesDir").unwrap();
8102 assert!(!result.is_empty());
8104 });
8105 }
8106
8107 #[test]
8110 fn test_api_read_dir() {
8111 let (mut backend, _rx) = create_test_backend();
8112
8113 backend
8114 .execute_js(
8115 r#"
8116 const editor = getEditor();
8117 const entries = editor.readDir("/tmp");
8118 globalThis._isArray = Array.isArray(entries);
8119 globalThis._length = entries.length;
8120 "#,
8121 "test.js",
8122 )
8123 .unwrap();
8124
8125 backend
8126 .plugin_contexts
8127 .borrow()
8128 .get("test")
8129 .unwrap()
8130 .clone()
8131 .with(|ctx| {
8132 let global = ctx.globals();
8133 let is_array: bool = global.get("_isArray").unwrap();
8134 let length: u32 = global.get("_length").unwrap();
8135 assert!(is_array);
8137 let _ = length;
8139 });
8140 }
8141
8142 #[test]
8145 fn test_api_execute_action() {
8146 let (mut backend, rx) = create_test_backend();
8147
8148 backend
8149 .execute_js(
8150 r#"
8151 const editor = getEditor();
8152 editor.executeAction("move_cursor_up");
8153 "#,
8154 "test.js",
8155 )
8156 .unwrap();
8157
8158 let cmd = rx.try_recv().unwrap();
8159 match cmd {
8160 PluginCommand::ExecuteAction { action_name } => {
8161 assert_eq!(action_name, "move_cursor_up");
8162 }
8163 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
8164 }
8165 }
8166
8167 #[test]
8170 fn test_api_debug() {
8171 let (mut backend, _rx) = create_test_backend();
8172
8173 backend
8175 .execute_js(
8176 r#"
8177 const editor = getEditor();
8178 editor.debug("Test debug message");
8179 editor.debug("Another message with special chars: <>&\"'");
8180 "#,
8181 "test.js",
8182 )
8183 .unwrap();
8184 }
8186
8187 #[test]
8190 fn test_typescript_preamble_generated() {
8191 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
8193 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
8194 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
8195 println!(
8196 "Generated {} bytes of TypeScript preamble",
8197 JSEDITORAPI_TS_PREAMBLE.len()
8198 );
8199 }
8200
8201 #[test]
8202 fn test_typescript_editor_api_generated() {
8203 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
8205 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
8206 println!(
8207 "Generated {} bytes of EditorAPI interface",
8208 JSEDITORAPI_TS_EDITOR_API.len()
8209 );
8210 }
8211
8212 #[test]
8213 fn test_js_methods_list() {
8214 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
8216 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
8217 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
8219 if i < 20 {
8220 println!(" - {}", method);
8221 }
8222 }
8223 if JSEDITORAPI_JS_METHODS.len() > 20 {
8224 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
8225 }
8226 }
8227
8228 #[test]
8231 fn test_api_load_plugin_sends_command() {
8232 let (mut backend, rx) = create_test_backend();
8233
8234 backend
8236 .execute_js(
8237 r#"
8238 const editor = getEditor();
8239 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
8240 "#,
8241 "test.js",
8242 )
8243 .unwrap();
8244
8245 let cmd = rx.try_recv().unwrap();
8247 match cmd {
8248 PluginCommand::LoadPlugin { path, callback_id } => {
8249 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
8250 assert!(callback_id.0 > 0); }
8252 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
8253 }
8254 }
8255
8256 #[test]
8257 fn test_api_unload_plugin_sends_command() {
8258 let (mut backend, rx) = create_test_backend();
8259
8260 backend
8262 .execute_js(
8263 r#"
8264 const editor = getEditor();
8265 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
8266 "#,
8267 "test.js",
8268 )
8269 .unwrap();
8270
8271 let cmd = rx.try_recv().unwrap();
8273 match cmd {
8274 PluginCommand::UnloadPlugin { name, callback_id } => {
8275 assert_eq!(name, "my-plugin");
8276 assert!(callback_id.0 > 0); }
8278 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
8279 }
8280 }
8281
8282 #[test]
8283 fn test_api_reload_plugin_sends_command() {
8284 let (mut backend, rx) = create_test_backend();
8285
8286 backend
8288 .execute_js(
8289 r#"
8290 const editor = getEditor();
8291 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
8292 "#,
8293 "test.js",
8294 )
8295 .unwrap();
8296
8297 let cmd = rx.try_recv().unwrap();
8299 match cmd {
8300 PluginCommand::ReloadPlugin { name, callback_id } => {
8301 assert_eq!(name, "my-plugin");
8302 assert!(callback_id.0 > 0); }
8304 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
8305 }
8306 }
8307
8308 #[test]
8309 fn test_api_load_plugin_resolves_callback() {
8310 let (mut backend, rx) = create_test_backend();
8311
8312 backend
8314 .execute_js(
8315 r#"
8316 const editor = getEditor();
8317 globalThis._loadResult = null;
8318 editor.loadPlugin("/path/to/plugin.ts").then(result => {
8319 globalThis._loadResult = result;
8320 });
8321 "#,
8322 "test.js",
8323 )
8324 .unwrap();
8325
8326 let callback_id = match rx.try_recv().unwrap() {
8328 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
8329 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
8330 };
8331
8332 backend.resolve_callback(callback_id, "true");
8334
8335 backend
8337 .plugin_contexts
8338 .borrow()
8339 .get("test")
8340 .unwrap()
8341 .clone()
8342 .with(|ctx| {
8343 run_pending_jobs_checked(&ctx, "test async loadPlugin");
8344 });
8345
8346 backend
8348 .plugin_contexts
8349 .borrow()
8350 .get("test")
8351 .unwrap()
8352 .clone()
8353 .with(|ctx| {
8354 let global = ctx.globals();
8355 let result: bool = global.get("_loadResult").unwrap();
8356 assert!(result);
8357 });
8358 }
8359
8360 #[test]
8361 fn test_api_version() {
8362 let (mut backend, _rx) = create_test_backend();
8363
8364 backend
8365 .execute_js(
8366 r#"
8367 const editor = getEditor();
8368 globalThis._apiVersion = editor.apiVersion();
8369 "#,
8370 "test.js",
8371 )
8372 .unwrap();
8373
8374 backend
8375 .plugin_contexts
8376 .borrow()
8377 .get("test")
8378 .unwrap()
8379 .clone()
8380 .with(|ctx| {
8381 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
8382 assert_eq!(version, 2);
8383 });
8384 }
8385
8386 #[test]
8387 fn test_api_unload_plugin_rejects_on_error() {
8388 let (mut backend, rx) = create_test_backend();
8389
8390 backend
8392 .execute_js(
8393 r#"
8394 const editor = getEditor();
8395 globalThis._unloadError = null;
8396 editor.unloadPlugin("nonexistent-plugin").catch(err => {
8397 globalThis._unloadError = err.message || String(err);
8398 });
8399 "#,
8400 "test.js",
8401 )
8402 .unwrap();
8403
8404 let callback_id = match rx.try_recv().unwrap() {
8406 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
8407 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
8408 };
8409
8410 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
8412
8413 backend
8415 .plugin_contexts
8416 .borrow()
8417 .get("test")
8418 .unwrap()
8419 .clone()
8420 .with(|ctx| {
8421 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
8422 });
8423
8424 backend
8426 .plugin_contexts
8427 .borrow()
8428 .get("test")
8429 .unwrap()
8430 .clone()
8431 .with(|ctx| {
8432 let global = ctx.globals();
8433 let error: String = global.get("_unloadError").unwrap();
8434 assert!(error.contains("nonexistent-plugin"));
8435 });
8436 }
8437
8438 #[test]
8439 fn test_api_set_global_state() {
8440 let (mut backend, rx) = create_test_backend();
8441
8442 backend
8443 .execute_js(
8444 r#"
8445 const editor = getEditor();
8446 editor.setGlobalState("myKey", { enabled: true, count: 42 });
8447 "#,
8448 "test_plugin.js",
8449 )
8450 .unwrap();
8451
8452 let cmd = rx.try_recv().unwrap();
8453 match cmd {
8454 PluginCommand::SetGlobalState {
8455 plugin_name,
8456 key,
8457 value,
8458 } => {
8459 assert_eq!(plugin_name, "test_plugin");
8460 assert_eq!(key, "myKey");
8461 let v = value.unwrap();
8462 assert_eq!(v["enabled"], serde_json::json!(true));
8463 assert_eq!(v["count"], serde_json::json!(42));
8464 }
8465 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
8466 }
8467 }
8468
8469 #[test]
8470 fn test_api_set_global_state_delete() {
8471 let (mut backend, rx) = create_test_backend();
8472
8473 backend
8474 .execute_js(
8475 r#"
8476 const editor = getEditor();
8477 editor.setGlobalState("myKey", null);
8478 "#,
8479 "test_plugin.js",
8480 )
8481 .unwrap();
8482
8483 let cmd = rx.try_recv().unwrap();
8484 match cmd {
8485 PluginCommand::SetGlobalState {
8486 plugin_name,
8487 key,
8488 value,
8489 } => {
8490 assert_eq!(plugin_name, "test_plugin");
8491 assert_eq!(key, "myKey");
8492 assert!(value.is_none(), "null should delete the key");
8493 }
8494 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
8495 }
8496 }
8497
8498 #[test]
8499 fn test_api_get_global_state_roundtrip() {
8500 let (mut backend, _rx) = create_test_backend();
8501
8502 backend
8504 .execute_js(
8505 r#"
8506 const editor = getEditor();
8507 editor.setGlobalState("flag", true);
8508 globalThis._result = editor.getGlobalState("flag");
8509 "#,
8510 "test_plugin.js",
8511 )
8512 .unwrap();
8513
8514 backend
8515 .plugin_contexts
8516 .borrow()
8517 .get("test_plugin")
8518 .unwrap()
8519 .clone()
8520 .with(|ctx| {
8521 let global = ctx.globals();
8522 let result: bool = global.get("_result").unwrap();
8523 assert!(
8524 result,
8525 "getGlobalState should return the value set by setGlobalState"
8526 );
8527 });
8528 }
8529
8530 #[test]
8531 fn test_api_get_global_state_missing_key() {
8532 let (mut backend, _rx) = create_test_backend();
8533
8534 backend
8535 .execute_js(
8536 r#"
8537 const editor = getEditor();
8538 globalThis._result = editor.getGlobalState("nonexistent");
8539 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
8540 "#,
8541 "test_plugin.js",
8542 )
8543 .unwrap();
8544
8545 backend
8546 .plugin_contexts
8547 .borrow()
8548 .get("test_plugin")
8549 .unwrap()
8550 .clone()
8551 .with(|ctx| {
8552 let global = ctx.globals();
8553 let is_undefined: bool = global.get("_isUndefined").unwrap();
8554 assert!(
8555 is_undefined,
8556 "getGlobalState for missing key should return undefined"
8557 );
8558 });
8559 }
8560
8561 #[test]
8562 fn test_api_global_state_isolation_between_plugins() {
8563 let (tx, _rx) = mpsc::channel();
8565 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8566 let services = Arc::new(TestServiceBridge::new());
8567
8568 let mut backend_a =
8570 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
8571 .unwrap();
8572 backend_a
8573 .execute_js(
8574 r#"
8575 const editor = getEditor();
8576 editor.setGlobalState("flag", "from_plugin_a");
8577 "#,
8578 "plugin_a.js",
8579 )
8580 .unwrap();
8581
8582 let mut backend_b =
8584 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
8585 .unwrap();
8586 backend_b
8587 .execute_js(
8588 r#"
8589 const editor = getEditor();
8590 editor.setGlobalState("flag", "from_plugin_b");
8591 "#,
8592 "plugin_b.js",
8593 )
8594 .unwrap();
8595
8596 backend_a
8598 .execute_js(
8599 r#"
8600 const editor = getEditor();
8601 globalThis._aValue = editor.getGlobalState("flag");
8602 "#,
8603 "plugin_a.js",
8604 )
8605 .unwrap();
8606
8607 backend_a
8608 .plugin_contexts
8609 .borrow()
8610 .get("plugin_a")
8611 .unwrap()
8612 .clone()
8613 .with(|ctx| {
8614 let global = ctx.globals();
8615 let a_value: String = global.get("_aValue").unwrap();
8616 assert_eq!(
8617 a_value, "from_plugin_a",
8618 "Plugin A should see its own value, not plugin B's"
8619 );
8620 });
8621
8622 backend_b
8624 .execute_js(
8625 r#"
8626 const editor = getEditor();
8627 globalThis._bValue = editor.getGlobalState("flag");
8628 "#,
8629 "plugin_b.js",
8630 )
8631 .unwrap();
8632
8633 backend_b
8634 .plugin_contexts
8635 .borrow()
8636 .get("plugin_b")
8637 .unwrap()
8638 .clone()
8639 .with(|ctx| {
8640 let global = ctx.globals();
8641 let b_value: String = global.get("_bValue").unwrap();
8642 assert_eq!(
8643 b_value, "from_plugin_b",
8644 "Plugin B should see its own value, not plugin A's"
8645 );
8646 });
8647 }
8648
8649 #[test]
8650 fn test_register_command_collision_different_plugins() {
8651 let (mut backend, _rx) = create_test_backend();
8652
8653 backend
8655 .execute_js(
8656 r#"
8657 const editor = getEditor();
8658 globalThis.handlerA = function() { };
8659 editor.registerCommand("My Command", "From A", "handlerA", null);
8660 "#,
8661 "plugin_a.js",
8662 )
8663 .unwrap();
8664
8665 let result = backend.execute_js(
8667 r#"
8668 const editor = getEditor();
8669 globalThis.handlerB = function() { };
8670 editor.registerCommand("My Command", "From B", "handlerB", null);
8671 "#,
8672 "plugin_b.js",
8673 );
8674
8675 assert!(
8676 result.is_err(),
8677 "Second plugin registering the same command name should fail"
8678 );
8679 let err_msg = result.unwrap_err().to_string();
8680 assert!(
8681 err_msg.contains("already registered"),
8682 "Error should mention collision: {}",
8683 err_msg
8684 );
8685 }
8686
8687 #[test]
8688 fn test_register_command_same_plugin_allowed() {
8689 let (mut backend, _rx) = create_test_backend();
8690
8691 backend
8693 .execute_js(
8694 r#"
8695 const editor = getEditor();
8696 globalThis.handler1 = function() { };
8697 editor.registerCommand("My Command", "Version 1", "handler1", null);
8698 globalThis.handler2 = function() { };
8699 editor.registerCommand("My Command", "Version 2", "handler2", null);
8700 "#,
8701 "plugin_a.js",
8702 )
8703 .unwrap();
8704 }
8705
8706 #[test]
8707 fn test_register_command_after_unregister() {
8708 let (mut backend, _rx) = create_test_backend();
8709
8710 backend
8712 .execute_js(
8713 r#"
8714 const editor = getEditor();
8715 globalThis.handlerA = function() { };
8716 editor.registerCommand("My Command", "From A", "handlerA", null);
8717 editor.unregisterCommand("My Command");
8718 "#,
8719 "plugin_a.js",
8720 )
8721 .unwrap();
8722
8723 backend
8725 .execute_js(
8726 r#"
8727 const editor = getEditor();
8728 globalThis.handlerB = function() { };
8729 editor.registerCommand("My Command", "From B", "handlerB", null);
8730 "#,
8731 "plugin_b.js",
8732 )
8733 .unwrap();
8734 }
8735
8736 #[test]
8737 fn test_register_command_collision_caught_in_try_catch() {
8738 let (mut backend, _rx) = create_test_backend();
8739
8740 backend
8742 .execute_js(
8743 r#"
8744 const editor = getEditor();
8745 globalThis.handlerA = function() { };
8746 editor.registerCommand("My Command", "From A", "handlerA", null);
8747 "#,
8748 "plugin_a.js",
8749 )
8750 .unwrap();
8751
8752 backend
8754 .execute_js(
8755 r#"
8756 const editor = getEditor();
8757 globalThis.handlerB = function() { };
8758 let caught = false;
8759 try {
8760 editor.registerCommand("My Command", "From B", "handlerB", null);
8761 } catch (e) {
8762 caught = true;
8763 }
8764 if (!caught) throw new Error("Expected collision error");
8765 "#,
8766 "plugin_b.js",
8767 )
8768 .unwrap();
8769 }
8770
8771 #[test]
8772 fn test_register_command_i18n_key_no_collision_across_plugins() {
8773 let (mut backend, _rx) = create_test_backend();
8774
8775 backend
8777 .execute_js(
8778 r#"
8779 const editor = getEditor();
8780 globalThis.handlerA = function() { };
8781 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
8782 "#,
8783 "plugin_a.js",
8784 )
8785 .unwrap();
8786
8787 backend
8790 .execute_js(
8791 r#"
8792 const editor = getEditor();
8793 globalThis.handlerB = function() { };
8794 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
8795 "#,
8796 "plugin_b.js",
8797 )
8798 .unwrap();
8799 }
8800
8801 #[test]
8802 fn test_register_command_non_i18n_still_collides() {
8803 let (mut backend, _rx) = create_test_backend();
8804
8805 backend
8807 .execute_js(
8808 r#"
8809 const editor = getEditor();
8810 globalThis.handlerA = function() { };
8811 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
8812 "#,
8813 "plugin_a.js",
8814 )
8815 .unwrap();
8816
8817 let result = backend.execute_js(
8819 r#"
8820 const editor = getEditor();
8821 globalThis.handlerB = function() { };
8822 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
8823 "#,
8824 "plugin_b.js",
8825 );
8826
8827 assert!(
8828 result.is_err(),
8829 "Non-%-prefixed names should still collide across plugins"
8830 );
8831 }
8832}