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
675#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
678#[rquickjs::class]
679pub struct JsEditorApi {
680 #[qjs(skip_trace)]
681 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
682 #[qjs(skip_trace)]
683 command_sender: mpsc::Sender<PluginCommand>,
684 #[qjs(skip_trace)]
685 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
686 #[qjs(skip_trace)]
687 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
688 #[qjs(skip_trace)]
689 next_request_id: Rc<RefCell<u64>>,
690 #[qjs(skip_trace)]
691 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
692 #[qjs(skip_trace)]
693 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
694 #[qjs(skip_trace)]
695 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
696 #[qjs(skip_trace)]
697 async_resource_owners: AsyncResourceOwners,
698 #[qjs(skip_trace)]
700 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
701 #[qjs(skip_trace)]
703 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
704 #[qjs(skip_trace)]
706 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
707 #[qjs(skip_trace)]
709 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
710 #[qjs(skip_trace)]
714 plugin_api_exports:
715 Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>,
716 pub plugin_name: String,
717}
718
719#[plugin_api_impl]
720#[rquickjs::methods(rename_all = "camelCase")]
721impl JsEditorApi {
722 pub fn api_version(&self) -> u32 {
727 2
728 }
729
730 pub fn plugin_name(&self) -> String {
734 self.plugin_name.clone()
735 }
736
737 #[plugin_api(ts_return = "boolean")]
747 pub fn export_plugin_api<'js>(
748 &self,
749 ctx: rquickjs::Ctx<'js>,
750 name: String,
751 api: rquickjs::Value<'js>,
752 ) -> rquickjs::Result<bool> {
753 if name.is_empty() {
754 let msg =
755 rquickjs::String::from_str(ctx.clone(), "exportPluginApi: name must be non-empty")?;
756 return Err(ctx.throw(msg.into_value()));
757 }
758 let obj = match api.as_object() {
759 Some(o) => o.clone(),
760 None => {
761 let msg = rquickjs::String::from_str(
762 ctx.clone(),
763 "exportPluginApi: api must be an object",
764 )?;
765 return Err(ctx.throw(msg.into_value()));
766 }
767 };
768 let persistent = rquickjs::Persistent::save(&ctx, obj);
769 self.plugin_api_exports
770 .borrow_mut()
771 .insert(name, (self.plugin_name.clone(), persistent));
772 Ok(true)
773 }
774
775 #[plugin_api(ts_return = "unknown | null")]
779 pub fn get_plugin_api<'js>(
780 &self,
781 ctx: rquickjs::Ctx<'js>,
782 name: String,
783 ) -> rquickjs::Result<rquickjs::Value<'js>> {
784 let persistent = self
785 .plugin_api_exports
786 .borrow()
787 .get(&name)
788 .map(|(_exporter, p)| p.clone());
789 match persistent {
790 Some(p) => {
791 let restored = p.restore(&ctx)?;
792 Ok(restored.into_value())
793 }
794 None => Ok(rquickjs::Value::new_null(ctx)),
795 }
796 }
797
798 pub fn get_active_buffer_id(&self) -> u32 {
800 self.state_snapshot
801 .read()
802 .map(|s| s.active_buffer_id.0 as u32)
803 .unwrap_or(0)
804 }
805
806 pub fn get_active_split_id(&self) -> u32 {
808 self.state_snapshot
809 .read()
810 .map(|s| s.active_split_id as u32)
811 .unwrap_or(0)
812 }
813
814 #[plugin_api(ts_return = "BufferInfo[]")]
816 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
817 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
818 s.buffers.values().cloned().collect()
819 } else {
820 Vec::new()
821 };
822 rquickjs_serde::to_value(ctx, &buffers)
823 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
824 }
825
826 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
828 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
829 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
830 s.available_grammars.clone()
831 } else {
832 Vec::new()
833 };
834 rquickjs_serde::to_value(ctx, &grammars)
835 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
836 }
837
838 pub fn debug(&self, msg: String) {
841 tracing::trace!("Plugin.debug: {}", msg);
842 }
843
844 pub fn info(&self, msg: String) {
845 tracing::info!("Plugin: {}", msg);
846 }
847
848 pub fn warn(&self, msg: String) {
849 tracing::warn!("Plugin: {}", msg);
850 }
851
852 pub fn error(&self, msg: String) {
853 tracing::error!("Plugin: {}", msg);
854 }
855
856 pub fn set_status(&self, msg: String) {
859 let _ = self
860 .command_sender
861 .send(PluginCommand::SetStatus { message: msg });
862 }
863
864 pub fn copy_to_clipboard(&self, text: String) {
867 let _ = self
868 .command_sender
869 .send(PluginCommand::SetClipboard { text });
870 }
871
872 pub fn set_clipboard(&self, text: String) {
873 let _ = self
874 .command_sender
875 .send(PluginCommand::SetClipboard { text });
876 }
877
878 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
883 if let Some(mode_name) = mode {
884 let key = format!("{}\0{}", action, mode_name);
885 if let Ok(snapshot) = self.state_snapshot.read() {
886 return snapshot.keybinding_labels.get(&key).cloned();
887 }
888 }
889 None
890 }
891
892 pub fn register_command<'js>(
903 &self,
904 ctx: rquickjs::Ctx<'js>,
905 name: String,
906 description: String,
907 handler_name: String,
908 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
909 rquickjs::Value<'js>,
910 >,
911 ) -> rquickjs::Result<bool> {
912 let plugin_name = self.plugin_name.clone();
914 let context_str: Option<String> = context.0.and_then(|v| {
916 if v.is_null() || v.is_undefined() {
917 None
918 } else {
919 v.as_string().and_then(|s| s.to_string().ok())
920 }
921 });
922
923 tracing::debug!(
924 "registerCommand: plugin='{}', name='{}', handler='{}'",
925 plugin_name,
926 name,
927 handler_name
928 );
929
930 let tracking_key = if name.starts_with('%') {
934 format!("{}:{}", plugin_name, name)
935 } else {
936 name.clone()
937 };
938 {
939 let names = self.registered_command_names.borrow();
940 if let Some(existing_plugin) = names.get(&tracking_key) {
941 if existing_plugin != &plugin_name {
942 let msg = format!(
943 "Command '{}' already registered by plugin '{}'",
944 name, existing_plugin
945 );
946 tracing::warn!("registerCommand collision: {}", msg);
947 return Err(
948 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
949 );
950 }
951 }
953 }
954
955 self.registered_command_names
957 .borrow_mut()
958 .insert(tracking_key, plugin_name.clone());
959
960 self.registered_actions.borrow_mut().insert(
962 handler_name.clone(),
963 PluginHandler {
964 plugin_name: self.plugin_name.clone(),
965 handler_name: handler_name.clone(),
966 },
967 );
968
969 let command = Command {
971 name: name.clone(),
972 description,
973 action_name: handler_name,
974 plugin_name,
975 custom_contexts: context_str.into_iter().collect(),
976 };
977
978 Ok(self
979 .command_sender
980 .send(PluginCommand::RegisterCommand { command })
981 .is_ok())
982 }
983
984 pub fn unregister_command(&self, name: String) -> bool {
986 let tracking_key = if name.starts_with('%') {
989 format!("{}:{}", self.plugin_name, name)
990 } else {
991 name.clone()
992 };
993 self.registered_command_names
994 .borrow_mut()
995 .remove(&tracking_key);
996 self.command_sender
997 .send(PluginCommand::UnregisterCommand { name })
998 .is_ok()
999 }
1000
1001 pub fn set_context(&self, name: String, active: bool) -> bool {
1003 if active {
1005 self.plugin_tracked_state
1006 .borrow_mut()
1007 .entry(self.plugin_name.clone())
1008 .or_default()
1009 .contexts_set
1010 .push(name.clone());
1011 }
1012 self.command_sender
1013 .send(PluginCommand::SetContext { name, active })
1014 .is_ok()
1015 }
1016
1017 pub fn execute_action(&self, action_name: String) -> bool {
1019 self.command_sender
1020 .send(PluginCommand::ExecuteAction { action_name })
1021 .is_ok()
1022 }
1023
1024 pub fn t<'js>(
1029 &self,
1030 _ctx: rquickjs::Ctx<'js>,
1031 key: String,
1032 args: rquickjs::function::Rest<Value<'js>>,
1033 ) -> String {
1034 let plugin_name = self.plugin_name.clone();
1036 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1038 if let Some(obj) = first_arg.as_object() {
1039 let mut map = HashMap::new();
1040 for k in obj.keys::<String>().flatten() {
1041 if let Ok(v) = obj.get::<_, String>(&k) {
1042 map.insert(k, v);
1043 }
1044 }
1045 map
1046 } else {
1047 HashMap::new()
1048 }
1049 } else {
1050 HashMap::new()
1051 };
1052 let res = self.services.translate(&plugin_name, &key, &args_map);
1053
1054 tracing::info!(
1055 "Translating: key={}, plugin={}, args={:?} => res='{}'",
1056 key,
1057 plugin_name,
1058 args_map,
1059 res
1060 );
1061 res
1062 }
1063
1064 pub fn get_cursor_position(&self) -> u32 {
1068 self.state_snapshot
1069 .read()
1070 .ok()
1071 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1072 .unwrap_or(0)
1073 }
1074
1075 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1077 if let Ok(s) = self.state_snapshot.read() {
1078 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1079 if let Some(p) = &b.path {
1080 return p.to_string_lossy().to_string();
1081 }
1082 }
1083 }
1084 String::new()
1085 }
1086
1087 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1089 if let Ok(s) = self.state_snapshot.read() {
1090 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1091 return b.length as u32;
1092 }
1093 }
1094 0
1095 }
1096
1097 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1099 if let Ok(s) = self.state_snapshot.read() {
1100 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1101 return b.modified;
1102 }
1103 }
1104 false
1105 }
1106
1107 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1110 self.command_sender
1111 .send(PluginCommand::SaveBufferToPath {
1112 buffer_id: BufferId(buffer_id as usize),
1113 path: std::path::PathBuf::from(path),
1114 })
1115 .is_ok()
1116 }
1117
1118 #[plugin_api(ts_return = "BufferInfo | null")]
1120 pub fn get_buffer_info<'js>(
1121 &self,
1122 ctx: rquickjs::Ctx<'js>,
1123 buffer_id: u32,
1124 ) -> rquickjs::Result<Value<'js>> {
1125 let info = if let Ok(s) = self.state_snapshot.read() {
1126 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1127 } else {
1128 None
1129 };
1130 rquickjs_serde::to_value(ctx, &info)
1131 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1132 }
1133
1134 #[plugin_api(ts_return = "CursorInfo | null")]
1136 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1137 let cursor = if let Ok(s) = self.state_snapshot.read() {
1138 s.primary_cursor.clone()
1139 } else {
1140 None
1141 };
1142 rquickjs_serde::to_value(ctx, &cursor)
1143 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1144 }
1145
1146 #[plugin_api(ts_return = "CursorInfo[]")]
1148 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1149 let cursors = if let Ok(s) = self.state_snapshot.read() {
1150 s.all_cursors.clone()
1151 } else {
1152 Vec::new()
1153 };
1154 rquickjs_serde::to_value(ctx, &cursors)
1155 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1156 }
1157
1158 #[plugin_api(ts_return = "number[]")]
1160 pub fn get_all_cursor_positions<'js>(
1161 &self,
1162 ctx: rquickjs::Ctx<'js>,
1163 ) -> rquickjs::Result<Value<'js>> {
1164 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1165 s.all_cursors.iter().map(|c| c.position as u32).collect()
1166 } else {
1167 Vec::new()
1168 };
1169 rquickjs_serde::to_value(ctx, &positions)
1170 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1171 }
1172
1173 #[plugin_api(ts_return = "ViewportInfo | null")]
1175 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1176 let viewport = if let Ok(s) = self.state_snapshot.read() {
1177 s.viewport.clone()
1178 } else {
1179 None
1180 };
1181 rquickjs_serde::to_value(ctx, &viewport)
1182 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1183 }
1184
1185 pub fn get_cursor_line(&self) -> u32 {
1187 0
1191 }
1192
1193 #[plugin_api(
1196 async_promise,
1197 js_name = "getLineStartPosition",
1198 ts_return = "number | null"
1199 )]
1200 #[qjs(rename = "_getLineStartPositionStart")]
1201 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1202 let id = {
1203 let mut id_ref = self.next_request_id.borrow_mut();
1204 let id = *id_ref;
1205 *id_ref += 1;
1206 self.callback_contexts
1208 .borrow_mut()
1209 .insert(id, self.plugin_name.clone());
1210 id
1211 };
1212 let _ = self
1214 .command_sender
1215 .send(PluginCommand::GetLineStartPosition {
1216 buffer_id: BufferId(0),
1217 line,
1218 request_id: id,
1219 });
1220 id
1221 }
1222
1223 #[plugin_api(
1227 async_promise,
1228 js_name = "getLineEndPosition",
1229 ts_return = "number | null"
1230 )]
1231 #[qjs(rename = "_getLineEndPositionStart")]
1232 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1233 let id = {
1234 let mut id_ref = self.next_request_id.borrow_mut();
1235 let id = *id_ref;
1236 *id_ref += 1;
1237 self.callback_contexts
1238 .borrow_mut()
1239 .insert(id, self.plugin_name.clone());
1240 id
1241 };
1242 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1244 buffer_id: BufferId(0),
1245 line,
1246 request_id: id,
1247 });
1248 id
1249 }
1250
1251 #[plugin_api(
1254 async_promise,
1255 js_name = "getBufferLineCount",
1256 ts_return = "number | null"
1257 )]
1258 #[qjs(rename = "_getBufferLineCountStart")]
1259 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1260 let id = {
1261 let mut id_ref = self.next_request_id.borrow_mut();
1262 let id = *id_ref;
1263 *id_ref += 1;
1264 self.callback_contexts
1265 .borrow_mut()
1266 .insert(id, self.plugin_name.clone());
1267 id
1268 };
1269 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1271 buffer_id: BufferId(0),
1272 request_id: id,
1273 });
1274 id
1275 }
1276
1277 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1280 self.command_sender
1281 .send(PluginCommand::ScrollToLineCenter {
1282 split_id: SplitId(split_id as usize),
1283 buffer_id: BufferId(buffer_id as usize),
1284 line: line as usize,
1285 })
1286 .is_ok()
1287 }
1288
1289 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1298 self.command_sender
1299 .send(PluginCommand::ScrollBufferToLine {
1300 buffer_id: BufferId(buffer_id as usize),
1301 line: line as usize,
1302 })
1303 .is_ok()
1304 }
1305
1306 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1308 let path_buf = std::path::PathBuf::from(&path);
1309 if let Ok(s) = self.state_snapshot.read() {
1310 for (id, info) in &s.buffers {
1311 if let Some(buf_path) = &info.path {
1312 if buf_path == &path_buf {
1313 return id.0 as u32;
1314 }
1315 }
1316 }
1317 }
1318 0
1319 }
1320
1321 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1323 pub fn get_buffer_saved_diff<'js>(
1324 &self,
1325 ctx: rquickjs::Ctx<'js>,
1326 buffer_id: u32,
1327 ) -> rquickjs::Result<Value<'js>> {
1328 let diff = if let Ok(s) = self.state_snapshot.read() {
1329 s.buffer_saved_diffs
1330 .get(&BufferId(buffer_id as usize))
1331 .cloned()
1332 } else {
1333 None
1334 };
1335 rquickjs_serde::to_value(ctx, &diff)
1336 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1337 }
1338
1339 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1343 self.command_sender
1344 .send(PluginCommand::InsertText {
1345 buffer_id: BufferId(buffer_id as usize),
1346 position: position as usize,
1347 text,
1348 })
1349 .is_ok()
1350 }
1351
1352 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1354 self.command_sender
1355 .send(PluginCommand::DeleteRange {
1356 buffer_id: BufferId(buffer_id as usize),
1357 range: (start as usize)..(end as usize),
1358 })
1359 .is_ok()
1360 }
1361
1362 pub fn insert_at_cursor(&self, text: String) -> bool {
1364 self.command_sender
1365 .send(PluginCommand::InsertAtCursor { text })
1366 .is_ok()
1367 }
1368
1369 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1373 self.command_sender
1374 .send(PluginCommand::OpenFileAtLocation {
1375 path: PathBuf::from(path),
1376 line: line.map(|l| l as usize),
1377 column: column.map(|c| c as usize),
1378 })
1379 .is_ok()
1380 }
1381
1382 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1384 self.command_sender
1385 .send(PluginCommand::OpenFileInSplit {
1386 split_id: split_id as usize,
1387 path: PathBuf::from(path),
1388 line: Some(line as usize),
1389 column: Some(column as usize),
1390 })
1391 .is_ok()
1392 }
1393
1394 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1396 self.command_sender
1397 .send(PluginCommand::ShowBuffer {
1398 buffer_id: BufferId(buffer_id as usize),
1399 })
1400 .is_ok()
1401 }
1402
1403 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1405 self.command_sender
1406 .send(PluginCommand::CloseBuffer {
1407 buffer_id: BufferId(buffer_id as usize),
1408 })
1409 .is_ok()
1410 }
1411
1412 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1416 if event_name == "lines_changed" {
1420 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1421 }
1422 self.event_handlers
1423 .borrow_mut()
1424 .entry(event_name)
1425 .or_default()
1426 .push(PluginHandler {
1427 plugin_name: self.plugin_name.clone(),
1428 handler_name,
1429 });
1430 }
1431
1432 pub fn off(&self, event_name: String, handler_name: String) {
1434 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1435 list.retain(|h| h.handler_name != handler_name);
1436 }
1437 }
1438
1439 pub fn get_env(&self, name: String) -> Option<String> {
1443 std::env::var(&name).ok()
1444 }
1445
1446 pub fn get_cwd(&self) -> String {
1448 self.state_snapshot
1449 .read()
1450 .map(|s| s.working_dir.to_string_lossy().to_string())
1451 .unwrap_or_else(|_| ".".to_string())
1452 }
1453
1454 pub fn get_authority_label(&self) -> String {
1463 self.state_snapshot
1464 .read()
1465 .map(|s| s.authority_label.clone())
1466 .unwrap_or_default()
1467 }
1468
1469 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1481 let mut result_parts: Vec<String> = Vec::new();
1482 let mut leading_slashes: u8 = 0;
1484
1485 for part in &parts.0 {
1486 let normalized = part.replace('\\', "/");
1488
1489 let is_absolute = normalized.starts_with('/')
1491 || (normalized.len() >= 2
1492 && normalized
1493 .chars()
1494 .next()
1495 .map(|c| c.is_ascii_alphabetic())
1496 .unwrap_or(false)
1497 && normalized.chars().nth(1) == Some(':'));
1498
1499 if is_absolute {
1500 result_parts.clear();
1502 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
1506 }
1507
1508 for segment in normalized.split('/') {
1510 if !segment.is_empty() && segment != "." {
1511 if segment == ".." {
1512 result_parts.pop();
1513 } else {
1514 result_parts.push(segment.to_string());
1515 }
1516 }
1517 }
1518 }
1519
1520 let joined = result_parts.join("/");
1522 let prefix = match leading_slashes {
1523 0 => "",
1524 1 => "/",
1525 _ => "//",
1526 };
1527
1528 if leading_slashes > 0 {
1529 format!("{}{}", prefix, joined)
1530 } else {
1531 joined
1532 }
1533 }
1534
1535 pub fn path_dirname(&self, path: String) -> String {
1537 Path::new(&path)
1538 .parent()
1539 .map(|p| p.to_string_lossy().to_string())
1540 .unwrap_or_default()
1541 }
1542
1543 pub fn path_basename(&self, path: String) -> String {
1545 Path::new(&path)
1546 .file_name()
1547 .map(|s| s.to_string_lossy().to_string())
1548 .unwrap_or_default()
1549 }
1550
1551 pub fn path_extname(&self, path: String) -> String {
1553 Path::new(&path)
1554 .extension()
1555 .map(|s| format!(".{}", s.to_string_lossy()))
1556 .unwrap_or_default()
1557 }
1558
1559 pub fn path_is_absolute(&self, path: String) -> bool {
1561 Path::new(&path).is_absolute()
1562 }
1563
1564 pub fn file_uri_to_path(&self, uri: String) -> String {
1568 fresh_core::file_uri::file_uri_to_path(&uri)
1569 .map(|p| p.to_string_lossy().to_string())
1570 .unwrap_or_default()
1571 }
1572
1573 pub fn path_to_file_uri(&self, path: String) -> String {
1577 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
1578 }
1579
1580 pub fn utf8_byte_length(&self, text: String) -> u32 {
1588 text.len() as u32
1589 }
1590
1591 pub fn file_exists(&self, path: String) -> bool {
1595 Path::new(&path).exists()
1596 }
1597
1598 pub fn read_file(&self, path: String) -> Option<String> {
1600 std::fs::read_to_string(&path).ok()
1601 }
1602
1603 pub fn write_file(&self, path: String, content: String) -> bool {
1605 let p = Path::new(&path);
1606 if let Some(parent) = p.parent() {
1607 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1608 return false;
1609 }
1610 }
1611 std::fs::write(p, content).is_ok()
1612 }
1613
1614 #[plugin_api(ts_return = "DirEntry[]")]
1616 pub fn read_dir<'js>(
1617 &self,
1618 ctx: rquickjs::Ctx<'js>,
1619 path: String,
1620 ) -> rquickjs::Result<Value<'js>> {
1621 use fresh_core::api::DirEntry;
1622
1623 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1624 Ok(entries) => entries
1625 .filter_map(|e| e.ok())
1626 .map(|entry| {
1627 let file_type = entry.file_type().ok();
1628 DirEntry {
1629 name: entry.file_name().to_string_lossy().to_string(),
1630 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1631 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1632 }
1633 })
1634 .collect(),
1635 Err(e) => {
1636 tracing::warn!("readDir failed for '{}': {}", path, e);
1637 Vec::new()
1638 }
1639 };
1640
1641 rquickjs_serde::to_value(ctx, &entries)
1642 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1643 }
1644
1645 pub fn create_dir(&self, path: String) -> bool {
1648 let p = Path::new(&path);
1649 if p.is_dir() {
1650 return true;
1651 }
1652 std::fs::create_dir_all(p).is_ok()
1653 }
1654
1655 pub fn remove_path(&self, path: String) -> bool {
1659 let target = match Path::new(&path).canonicalize() {
1660 Ok(p) => p,
1661 Err(_) => return false, };
1663
1664 let temp_dir = std::env::temp_dir()
1670 .canonicalize()
1671 .unwrap_or_else(|_| std::env::temp_dir());
1672 let config_dir = self
1673 .services
1674 .config_dir()
1675 .canonicalize()
1676 .unwrap_or_else(|_| self.services.config_dir());
1677
1678 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
1680 if !allowed {
1681 tracing::warn!(
1682 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
1683 target,
1684 temp_dir,
1685 config_dir
1686 );
1687 return false;
1688 }
1689
1690 if target == temp_dir || target == config_dir {
1692 tracing::warn!(
1693 "removePath refused: cannot remove root directory {:?}",
1694 target
1695 );
1696 return false;
1697 }
1698
1699 match trash::delete(&target) {
1700 Ok(()) => true,
1701 Err(e) => {
1702 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
1703 false
1704 }
1705 }
1706 }
1707
1708 pub fn rename_path(&self, from: String, to: String) -> bool {
1711 if std::fs::rename(&from, &to).is_ok() {
1713 return true;
1714 }
1715 let from_path = Path::new(&from);
1717 let copied = if from_path.is_dir() {
1718 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
1719 } else {
1720 std::fs::copy(&from, &to).is_ok()
1721 };
1722 if copied {
1723 return trash::delete(from_path).is_ok();
1724 }
1725 false
1726 }
1727
1728 pub fn copy_path(&self, from: String, to: String) -> bool {
1731 let from_path = Path::new(&from);
1732 let to_path = Path::new(&to);
1733 if from_path.is_dir() {
1734 copy_dir_recursive(from_path, to_path).is_ok()
1735 } else {
1736 if let Some(parent) = to_path.parent() {
1738 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1739 return false;
1740 }
1741 }
1742 std::fs::copy(from_path, to_path).is_ok()
1743 }
1744 }
1745
1746 pub fn get_temp_dir(&self) -> String {
1748 std::env::temp_dir().to_string_lossy().to_string()
1749 }
1750
1751 #[plugin_api(ts_return = "unknown")]
1762 pub fn parse_jsonc<'js>(
1763 &self,
1764 ctx: rquickjs::Ctx<'js>,
1765 text: String,
1766 ) -> rquickjs::Result<Value<'js>> {
1767 let value: serde_json::Value =
1768 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
1769 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
1770 })?;
1771 rquickjs_serde::to_value(ctx, &value)
1772 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1773 }
1774
1775 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1784 let config = self
1785 .state_snapshot
1786 .read()
1787 .map(|s| std::sync::Arc::clone(&s.config))
1788 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1789
1790 rquickjs_serde::to_value(ctx, &*config)
1791 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1792 }
1793
1794 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1796 let config = self
1797 .state_snapshot
1798 .read()
1799 .map(|s| std::sync::Arc::clone(&s.user_config))
1800 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1801
1802 rquickjs_serde::to_value(ctx, &*config)
1803 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1804 }
1805
1806 pub fn reload_config(&self) {
1808 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1809 }
1810
1811 pub fn set_setting<'js>(
1824 &self,
1825 _ctx: rquickjs::Ctx<'js>,
1826 path: String,
1827 value: Value<'js>,
1828 ) -> rquickjs::Result<bool> {
1829 let json: serde_json::Value = rquickjs_serde::from_value(value)
1830 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
1831 Ok(self
1832 .command_sender
1833 .send(PluginCommand::SetSetting {
1834 plugin_name: self.plugin_name.clone(),
1835 path,
1836 value: json,
1837 })
1838 .is_ok())
1839 }
1840
1841 pub fn reload_themes(&self) {
1844 let _ = self
1845 .command_sender
1846 .send(PluginCommand::ReloadThemes { apply_theme: None });
1847 }
1848
1849 pub fn reload_and_apply_theme(&self, theme_name: String) {
1851 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1852 apply_theme: Some(theme_name),
1853 });
1854 }
1855
1856 pub fn register_grammar<'js>(
1859 &self,
1860 ctx: rquickjs::Ctx<'js>,
1861 language: String,
1862 grammar_path: String,
1863 extensions: Vec<String>,
1864 ) -> rquickjs::Result<bool> {
1865 {
1867 let langs = self.registered_grammar_languages.borrow();
1868 if let Some(existing_plugin) = langs.get(&language) {
1869 if existing_plugin != &self.plugin_name {
1870 let msg = format!(
1871 "Grammar for language '{}' already registered by plugin '{}'",
1872 language, existing_plugin
1873 );
1874 tracing::warn!("registerGrammar collision: {}", msg);
1875 return Err(
1876 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1877 );
1878 }
1879 }
1880 }
1881 self.registered_grammar_languages
1882 .borrow_mut()
1883 .insert(language.clone(), self.plugin_name.clone());
1884
1885 Ok(self
1886 .command_sender
1887 .send(PluginCommand::RegisterGrammar {
1888 language,
1889 grammar_path,
1890 extensions,
1891 })
1892 .is_ok())
1893 }
1894
1895 pub fn register_language_config<'js>(
1897 &self,
1898 ctx: rquickjs::Ctx<'js>,
1899 language: String,
1900 config: LanguagePackConfig,
1901 ) -> rquickjs::Result<bool> {
1902 {
1904 let langs = self.registered_language_configs.borrow();
1905 if let Some(existing_plugin) = langs.get(&language) {
1906 if existing_plugin != &self.plugin_name {
1907 let msg = format!(
1908 "Language config for '{}' already registered by plugin '{}'",
1909 language, existing_plugin
1910 );
1911 tracing::warn!("registerLanguageConfig collision: {}", msg);
1912 return Err(
1913 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1914 );
1915 }
1916 }
1917 }
1918 self.registered_language_configs
1919 .borrow_mut()
1920 .insert(language.clone(), self.plugin_name.clone());
1921
1922 Ok(self
1923 .command_sender
1924 .send(PluginCommand::RegisterLanguageConfig { language, config })
1925 .is_ok())
1926 }
1927
1928 pub fn register_lsp_server<'js>(
1930 &self,
1931 ctx: rquickjs::Ctx<'js>,
1932 language: String,
1933 config: LspServerPackConfig,
1934 ) -> rquickjs::Result<bool> {
1935 {
1937 let langs = self.registered_lsp_servers.borrow();
1938 if let Some(existing_plugin) = langs.get(&language) {
1939 if existing_plugin != &self.plugin_name {
1940 let msg = format!(
1941 "LSP server for language '{}' already registered by plugin '{}'",
1942 language, existing_plugin
1943 );
1944 tracing::warn!("registerLspServer collision: {}", msg);
1945 return Err(
1946 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1947 );
1948 }
1949 }
1950 }
1951 self.registered_lsp_servers
1952 .borrow_mut()
1953 .insert(language.clone(), self.plugin_name.clone());
1954
1955 Ok(self
1956 .command_sender
1957 .send(PluginCommand::RegisterLspServer { language, config })
1958 .is_ok())
1959 }
1960
1961 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1965 #[qjs(rename = "_reloadGrammarsStart")]
1966 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1967 let id = {
1968 let mut id_ref = self.next_request_id.borrow_mut();
1969 let id = *id_ref;
1970 *id_ref += 1;
1971 self.callback_contexts
1972 .borrow_mut()
1973 .insert(id, self.plugin_name.clone());
1974 id
1975 };
1976 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1977 callback_id: fresh_core::api::JsCallbackId::new(id),
1978 });
1979 id
1980 }
1981
1982 pub fn get_plugin_dir(&self) -> String {
1985 self.services
1986 .plugins_dir()
1987 .join("packages")
1988 .join(&self.plugin_name)
1989 .to_string_lossy()
1990 .to_string()
1991 }
1992
1993 pub fn get_config_dir(&self) -> String {
1995 self.services.config_dir().to_string_lossy().to_string()
1996 }
1997
1998 pub fn get_data_dir(&self) -> String {
2002 self.services.data_dir().to_string_lossy().to_string()
2003 }
2004
2005 pub fn get_themes_dir(&self) -> String {
2007 self.services
2008 .config_dir()
2009 .join("themes")
2010 .to_string_lossy()
2011 .to_string()
2012 }
2013
2014 pub fn apply_theme(&self, theme_name: String) -> bool {
2016 self.command_sender
2017 .send(PluginCommand::ApplyTheme { theme_name })
2018 .is_ok()
2019 }
2020
2021 pub fn override_theme_colors<'js>(
2030 &self,
2031 _ctx: rquickjs::Ctx<'js>,
2032 overrides: Value<'js>,
2033 ) -> rquickjs::Result<bool> {
2034 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
2040 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
2041 let Some(obj) = json.as_object() else {
2042 return Err(rquickjs::Error::new_from_js_message(
2043 "type",
2044 "",
2045 "overrideThemeColors expects an object of \"key\": [r, g, b]",
2046 ));
2047 };
2048 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
2049 n.as_i64()
2050 .or_else(|| n.as_f64().map(|f| f as i64))
2051 .map(|v| v.clamp(0, 255) as u8)
2052 };
2053 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
2054 std::collections::HashMap::with_capacity(obj.len());
2055 for (key, value) in obj {
2056 let Some(arr) = value.as_array() else {
2057 continue;
2058 };
2059 if arr.len() != 3 {
2060 continue;
2061 }
2062 let Some(r) = to_u8(&arr[0]) else { continue };
2063 let Some(g) = to_u8(&arr[1]) else { continue };
2064 let Some(b) = to_u8(&arr[2]) else { continue };
2065 clamped.insert(key.clone(), [r, g, b]);
2066 }
2067 Ok(self
2068 .command_sender
2069 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
2070 .is_ok())
2071 }
2072
2073 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2075 let schema = self.services.get_theme_schema();
2076 rquickjs_serde::to_value(ctx, &schema)
2077 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2078 }
2079
2080 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2082 let themes = self.services.get_builtin_themes();
2083 rquickjs_serde::to_value(ctx, &themes)
2084 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2085 }
2086
2087 #[qjs(rename = "_deleteThemeSync")]
2089 pub fn delete_theme_sync(&self, name: String) -> bool {
2090 let themes_dir = self.services.config_dir().join("themes");
2092 let theme_path = themes_dir.join(format!("{}.json", name));
2093
2094 if let Ok(canonical) = theme_path.canonicalize() {
2096 if let Ok(themes_canonical) = themes_dir.canonicalize() {
2097 if canonical.starts_with(&themes_canonical) {
2098 return std::fs::remove_file(&canonical).is_ok();
2099 }
2100 }
2101 }
2102 false
2103 }
2104
2105 pub fn delete_theme(&self, name: String) -> bool {
2107 self.delete_theme_sync(name)
2108 }
2109
2110 pub fn get_theme_data<'js>(
2112 &self,
2113 ctx: rquickjs::Ctx<'js>,
2114 name: String,
2115 ) -> rquickjs::Result<Value<'js>> {
2116 match self.services.get_theme_data(&name) {
2117 Some(data) => rquickjs_serde::to_value(ctx, &data)
2118 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
2119 None => Ok(Value::new_null(ctx)),
2120 }
2121 }
2122
2123 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
2125 self.services
2126 .save_theme_file(&name, &content)
2127 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
2128 }
2129
2130 pub fn theme_file_exists(&self, name: String) -> bool {
2132 self.services.theme_file_exists(&name)
2133 }
2134
2135 pub fn file_stat<'js>(
2139 &self,
2140 ctx: rquickjs::Ctx<'js>,
2141 path: String,
2142 ) -> rquickjs::Result<Value<'js>> {
2143 let metadata = std::fs::metadata(&path).ok();
2144 let stat = metadata.map(|m| {
2145 serde_json::json!({
2146 "isFile": m.is_file(),
2147 "isDir": m.is_dir(),
2148 "size": m.len(),
2149 "readonly": m.permissions().readonly(),
2150 })
2151 });
2152 rquickjs_serde::to_value(ctx, &stat)
2153 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2154 }
2155
2156 pub fn is_process_running(&self, _process_id: u64) -> bool {
2160 false
2163 }
2164
2165 pub fn kill_process(&self, process_id: u64) -> bool {
2167 self.command_sender
2168 .send(PluginCommand::KillBackgroundProcess { process_id })
2169 .is_ok()
2170 }
2171
2172 pub fn plugin_translate<'js>(
2176 &self,
2177 _ctx: rquickjs::Ctx<'js>,
2178 plugin_name: String,
2179 key: String,
2180 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
2181 ) -> String {
2182 let args_map: HashMap<String, String> = args
2183 .0
2184 .map(|obj| {
2185 let mut map = HashMap::new();
2186 for (k, v) in obj.props::<String, String>().flatten() {
2187 map.insert(k, v);
2188 }
2189 map
2190 })
2191 .unwrap_or_default();
2192
2193 self.services.translate(&plugin_name, &key, &args_map)
2194 }
2195
2196 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
2203 #[qjs(rename = "_createCompositeBufferStart")]
2204 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
2205 let id = {
2206 let mut id_ref = self.next_request_id.borrow_mut();
2207 let id = *id_ref;
2208 *id_ref += 1;
2209 self.callback_contexts
2211 .borrow_mut()
2212 .insert(id, self.plugin_name.clone());
2213 id
2214 };
2215
2216 if let Ok(mut owners) = self.async_resource_owners.lock() {
2218 owners.insert(id, self.plugin_name.clone());
2219 }
2220 let _ = self
2221 .command_sender
2222 .send(PluginCommand::CreateCompositeBuffer {
2223 name: opts.name,
2224 mode: opts.mode,
2225 layout: opts.layout,
2226 sources: opts.sources,
2227 hunks: opts.hunks,
2228 initial_focus_hunk: opts.initial_focus_hunk,
2229 request_id: Some(id),
2230 });
2231
2232 id
2233 }
2234
2235 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
2239 self.command_sender
2240 .send(PluginCommand::UpdateCompositeAlignment {
2241 buffer_id: BufferId(buffer_id as usize),
2242 hunks,
2243 })
2244 .is_ok()
2245 }
2246
2247 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
2249 self.command_sender
2250 .send(PluginCommand::CloseCompositeBuffer {
2251 buffer_id: BufferId(buffer_id as usize),
2252 })
2253 .is_ok()
2254 }
2255
2256 pub fn flush_layout(&self) -> bool {
2260 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
2261 }
2262
2263 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
2265 self.command_sender
2266 .send(PluginCommand::CompositeNextHunk {
2267 buffer_id: BufferId(buffer_id as usize),
2268 })
2269 .is_ok()
2270 }
2271
2272 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
2274 self.command_sender
2275 .send(PluginCommand::CompositePrevHunk {
2276 buffer_id: BufferId(buffer_id as usize),
2277 })
2278 .is_ok()
2279 }
2280
2281 #[plugin_api(
2285 async_promise,
2286 js_name = "getHighlights",
2287 ts_return = "TsHighlightSpan[]"
2288 )]
2289 #[qjs(rename = "_getHighlightsStart")]
2290 pub fn get_highlights_start<'js>(
2291 &self,
2292 _ctx: rquickjs::Ctx<'js>,
2293 buffer_id: u32,
2294 start: u32,
2295 end: u32,
2296 ) -> rquickjs::Result<u64> {
2297 let id = {
2298 let mut id_ref = self.next_request_id.borrow_mut();
2299 let id = *id_ref;
2300 *id_ref += 1;
2301 self.callback_contexts
2303 .borrow_mut()
2304 .insert(id, self.plugin_name.clone());
2305 id
2306 };
2307
2308 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2309 buffer_id: BufferId(buffer_id as usize),
2310 range: (start as usize)..(end as usize),
2311 request_id: id,
2312 });
2313
2314 Ok(id)
2315 }
2316
2317 pub fn add_overlay<'js>(
2339 &self,
2340 _ctx: rquickjs::Ctx<'js>,
2341 buffer_id: u32,
2342 namespace: String,
2343 start: u32,
2344 end: u32,
2345 options: rquickjs::Object<'js>,
2346 ) -> rquickjs::Result<bool> {
2347 use fresh_core::api::OverlayColorSpec;
2348
2349 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2351 if let Ok(theme_key) = obj.get::<_, String>(key) {
2353 if !theme_key.is_empty() {
2354 return Some(OverlayColorSpec::ThemeKey(theme_key));
2355 }
2356 }
2357 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2359 if arr.len() >= 3 {
2360 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2361 }
2362 }
2363 None
2364 }
2365
2366 let fg = parse_color_spec("fg", &options);
2367 let bg = parse_color_spec("bg", &options);
2368 let underline: bool = options.get("underline").unwrap_or(false);
2369 let bold: bool = options.get("bold").unwrap_or(false);
2370 let italic: bool = options.get("italic").unwrap_or(false);
2371 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2372 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2373 let url: Option<String> = options.get("url").ok();
2374
2375 let options = OverlayOptions {
2376 fg,
2377 bg,
2378 underline,
2379 bold,
2380 italic,
2381 strikethrough,
2382 extend_to_line_end,
2383 url,
2384 };
2385
2386 self.plugin_tracked_state
2388 .borrow_mut()
2389 .entry(self.plugin_name.clone())
2390 .or_default()
2391 .overlay_namespaces
2392 .push((BufferId(buffer_id as usize), namespace.clone()));
2393
2394 let _ = self.command_sender.send(PluginCommand::AddOverlay {
2395 buffer_id: BufferId(buffer_id as usize),
2396 namespace: Some(OverlayNamespace::from_string(namespace)),
2397 range: (start as usize)..(end as usize),
2398 options,
2399 });
2400
2401 Ok(true)
2402 }
2403
2404 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2406 self.command_sender
2407 .send(PluginCommand::ClearNamespace {
2408 buffer_id: BufferId(buffer_id as usize),
2409 namespace: OverlayNamespace::from_string(namespace),
2410 })
2411 .is_ok()
2412 }
2413
2414 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2416 self.command_sender
2417 .send(PluginCommand::ClearAllOverlays {
2418 buffer_id: BufferId(buffer_id as usize),
2419 })
2420 .is_ok()
2421 }
2422
2423 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2425 self.command_sender
2426 .send(PluginCommand::ClearOverlaysInRange {
2427 buffer_id: BufferId(buffer_id as usize),
2428 start: start as usize,
2429 end: end as usize,
2430 })
2431 .is_ok()
2432 }
2433
2434 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2436 use fresh_core::overlay::OverlayHandle;
2437 self.command_sender
2438 .send(PluginCommand::RemoveOverlay {
2439 buffer_id: BufferId(buffer_id as usize),
2440 handle: OverlayHandle(handle),
2441 })
2442 .is_ok()
2443 }
2444
2445 pub fn add_conceal(
2449 &self,
2450 buffer_id: u32,
2451 namespace: String,
2452 start: u32,
2453 end: u32,
2454 replacement: Option<String>,
2455 ) -> bool {
2456 self.plugin_tracked_state
2458 .borrow_mut()
2459 .entry(self.plugin_name.clone())
2460 .or_default()
2461 .overlay_namespaces
2462 .push((BufferId(buffer_id as usize), namespace.clone()));
2463
2464 self.command_sender
2465 .send(PluginCommand::AddConceal {
2466 buffer_id: BufferId(buffer_id as usize),
2467 namespace: OverlayNamespace::from_string(namespace),
2468 start: start as usize,
2469 end: end as usize,
2470 replacement,
2471 })
2472 .is_ok()
2473 }
2474
2475 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2477 self.command_sender
2478 .send(PluginCommand::ClearConcealNamespace {
2479 buffer_id: BufferId(buffer_id as usize),
2480 namespace: OverlayNamespace::from_string(namespace),
2481 })
2482 .is_ok()
2483 }
2484
2485 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2487 self.command_sender
2488 .send(PluginCommand::ClearConcealsInRange {
2489 buffer_id: BufferId(buffer_id as usize),
2490 start: start as usize,
2491 end: end as usize,
2492 })
2493 .is_ok()
2494 }
2495
2496 pub fn add_fold(
2503 &self,
2504 buffer_id: u32,
2505 start: u32,
2506 end: u32,
2507 placeholder: rquickjs::function::Opt<String>,
2508 ) -> bool {
2509 self.command_sender
2510 .send(PluginCommand::AddFold {
2511 buffer_id: BufferId(buffer_id as usize),
2512 start: start as usize,
2513 end: end as usize,
2514 placeholder: placeholder.0,
2515 })
2516 .is_ok()
2517 }
2518
2519 pub fn clear_folds(&self, buffer_id: u32) -> bool {
2521 self.command_sender
2522 .send(PluginCommand::ClearFolds {
2523 buffer_id: BufferId(buffer_id as usize),
2524 })
2525 .is_ok()
2526 }
2527
2528 pub fn add_soft_break(
2532 &self,
2533 buffer_id: u32,
2534 namespace: String,
2535 position: u32,
2536 indent: u32,
2537 ) -> bool {
2538 self.plugin_tracked_state
2540 .borrow_mut()
2541 .entry(self.plugin_name.clone())
2542 .or_default()
2543 .overlay_namespaces
2544 .push((BufferId(buffer_id as usize), namespace.clone()));
2545
2546 self.command_sender
2547 .send(PluginCommand::AddSoftBreak {
2548 buffer_id: BufferId(buffer_id as usize),
2549 namespace: OverlayNamespace::from_string(namespace),
2550 position: position as usize,
2551 indent: indent as u16,
2552 })
2553 .is_ok()
2554 }
2555
2556 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2558 self.command_sender
2559 .send(PluginCommand::ClearSoftBreakNamespace {
2560 buffer_id: BufferId(buffer_id as usize),
2561 namespace: OverlayNamespace::from_string(namespace),
2562 })
2563 .is_ok()
2564 }
2565
2566 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2568 self.command_sender
2569 .send(PluginCommand::ClearSoftBreaksInRange {
2570 buffer_id: BufferId(buffer_id as usize),
2571 start: start as usize,
2572 end: end as usize,
2573 })
2574 .is_ok()
2575 }
2576
2577 #[allow(clippy::too_many_arguments)]
2587 pub fn submit_view_transform<'js>(
2588 &self,
2589 _ctx: rquickjs::Ctx<'js>,
2590 buffer_id: u32,
2591 split_id: Option<u32>,
2592 start: u32,
2593 end: u32,
2594 tokens: Vec<rquickjs::Object<'js>>,
2595 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2596 ) -> rquickjs::Result<bool> {
2597 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2598
2599 let tokens: Vec<ViewTokenWire> = tokens
2600 .into_iter()
2601 .enumerate()
2602 .map(|(idx, obj)| {
2603 parse_view_token(&obj, idx)
2605 })
2606 .collect::<rquickjs::Result<Vec<_>>>()?;
2607
2608 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2610 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2611 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2612 Some(LayoutHints {
2613 compose_width,
2614 column_guides,
2615 })
2616 } else {
2617 None
2618 };
2619
2620 let payload = ViewTransformPayload {
2621 range: (start as usize)..(end as usize),
2622 tokens,
2623 layout_hints: parsed_layout_hints,
2624 };
2625
2626 Ok(self
2627 .command_sender
2628 .send(PluginCommand::SubmitViewTransform {
2629 buffer_id: BufferId(buffer_id as usize),
2630 split_id: split_id.map(|id| SplitId(id as usize)),
2631 payload,
2632 })
2633 .is_ok())
2634 }
2635
2636 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2638 self.command_sender
2639 .send(PluginCommand::ClearViewTransform {
2640 buffer_id: BufferId(buffer_id as usize),
2641 split_id: split_id.map(|id| SplitId(id as usize)),
2642 })
2643 .is_ok()
2644 }
2645
2646 pub fn set_layout_hints<'js>(
2649 &self,
2650 buffer_id: u32,
2651 split_id: Option<u32>,
2652 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2653 ) -> rquickjs::Result<bool> {
2654 use fresh_core::api::LayoutHints;
2655
2656 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2657 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2658 let parsed_hints = LayoutHints {
2659 compose_width,
2660 column_guides,
2661 };
2662
2663 Ok(self
2664 .command_sender
2665 .send(PluginCommand::SetLayoutHints {
2666 buffer_id: BufferId(buffer_id as usize),
2667 split_id: split_id.map(|id| SplitId(id as usize)),
2668 range: 0..0,
2669 hints: parsed_hints,
2670 })
2671 .is_ok())
2672 }
2673
2674 pub fn set_file_explorer_decorations<'js>(
2678 &self,
2679 _ctx: rquickjs::Ctx<'js>,
2680 namespace: String,
2681 decorations: Vec<rquickjs::Object<'js>>,
2682 ) -> rquickjs::Result<bool> {
2683 use fresh_core::file_explorer::FileExplorerDecoration;
2684
2685 let decorations: Vec<FileExplorerDecoration> = decorations
2686 .into_iter()
2687 .map(|obj| {
2688 let path: String = obj.get("path")?;
2689 let symbol: String = obj.get("symbol")?;
2690 let priority: i32 = obj.get("priority").unwrap_or(0);
2691
2692 let color_val: rquickjs::Value = obj.get("color")?;
2694 let color = if color_val.is_string() {
2695 let key: String = color_val.get()?;
2696 fresh_core::api::OverlayColorSpec::ThemeKey(key)
2697 } else if color_val.is_array() {
2698 let arr: Vec<u8> = color_val.get()?;
2699 if arr.len() < 3 {
2700 return Err(rquickjs::Error::FromJs {
2701 from: "array",
2702 to: "color",
2703 message: Some(format!(
2704 "color array must have at least 3 elements, got {}",
2705 arr.len()
2706 )),
2707 });
2708 }
2709 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
2710 } else {
2711 return Err(rquickjs::Error::FromJs {
2712 from: "value",
2713 to: "color",
2714 message: Some("color must be an RGB array or theme key string".to_string()),
2715 });
2716 };
2717
2718 Ok(FileExplorerDecoration {
2719 path: std::path::PathBuf::from(path),
2720 symbol,
2721 color,
2722 priority,
2723 })
2724 })
2725 .collect::<rquickjs::Result<Vec<_>>>()?;
2726
2727 self.plugin_tracked_state
2729 .borrow_mut()
2730 .entry(self.plugin_name.clone())
2731 .or_default()
2732 .file_explorer_namespaces
2733 .push(namespace.clone());
2734
2735 Ok(self
2736 .command_sender
2737 .send(PluginCommand::SetFileExplorerDecorations {
2738 namespace,
2739 decorations,
2740 })
2741 .is_ok())
2742 }
2743
2744 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2746 self.command_sender
2747 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2748 .is_ok()
2749 }
2750
2751 #[allow(clippy::too_many_arguments)]
2755 pub fn add_virtual_text(
2756 &self,
2757 buffer_id: u32,
2758 virtual_text_id: String,
2759 position: u32,
2760 text: String,
2761 r: u8,
2762 g: u8,
2763 b: u8,
2764 before: bool,
2765 use_bg: bool,
2766 ) -> bool {
2767 self.plugin_tracked_state
2769 .borrow_mut()
2770 .entry(self.plugin_name.clone())
2771 .or_default()
2772 .virtual_text_ids
2773 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2774
2775 self.command_sender
2776 .send(PluginCommand::AddVirtualText {
2777 buffer_id: BufferId(buffer_id as usize),
2778 virtual_text_id,
2779 position: position as usize,
2780 text,
2781 color: (r, g, b),
2782 use_bg,
2783 before,
2784 })
2785 .is_ok()
2786 }
2787
2788 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2790 self.command_sender
2791 .send(PluginCommand::RemoveVirtualText {
2792 buffer_id: BufferId(buffer_id as usize),
2793 virtual_text_id,
2794 })
2795 .is_ok()
2796 }
2797
2798 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2800 self.command_sender
2801 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2802 buffer_id: BufferId(buffer_id as usize),
2803 prefix,
2804 })
2805 .is_ok()
2806 }
2807
2808 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2810 self.command_sender
2811 .send(PluginCommand::ClearVirtualTexts {
2812 buffer_id: BufferId(buffer_id as usize),
2813 })
2814 .is_ok()
2815 }
2816
2817 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2819 self.command_sender
2820 .send(PluginCommand::ClearVirtualTextNamespace {
2821 buffer_id: BufferId(buffer_id as usize),
2822 namespace,
2823 })
2824 .is_ok()
2825 }
2826
2827 #[allow(clippy::too_many_arguments)]
2835 pub fn add_virtual_line<'js>(
2836 &self,
2837 _ctx: rquickjs::Ctx<'js>,
2838 buffer_id: u32,
2839 position: u32,
2840 text: String,
2841 options: rquickjs::Object<'js>,
2842 above: bool,
2843 namespace: String,
2844 priority: i32,
2845 ) -> rquickjs::Result<bool> {
2846 use fresh_core::api::OverlayColorSpec;
2847
2848 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2851 if let Ok(theme_key) = obj.get::<_, String>(key) {
2852 if !theme_key.is_empty() {
2853 return Some(OverlayColorSpec::ThemeKey(theme_key));
2854 }
2855 }
2856 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2857 if arr.len() >= 3 {
2858 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2859 }
2860 }
2861 None
2862 }
2863
2864 let fg_color = parse_color_spec("fg", &options);
2865 let bg_color = parse_color_spec("bg", &options);
2866
2867 self.plugin_tracked_state
2869 .borrow_mut()
2870 .entry(self.plugin_name.clone())
2871 .or_default()
2872 .virtual_line_namespaces
2873 .push((BufferId(buffer_id as usize), namespace.clone()));
2874
2875 Ok(self
2876 .command_sender
2877 .send(PluginCommand::AddVirtualLine {
2878 buffer_id: BufferId(buffer_id as usize),
2879 position: position as usize,
2880 text,
2881 fg_color,
2882 bg_color,
2883 above,
2884 namespace,
2885 priority,
2886 })
2887 .is_ok())
2888 }
2889
2890 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2895 #[qjs(rename = "_promptStart")]
2896 pub fn prompt_start(
2897 &self,
2898 _ctx: rquickjs::Ctx<'_>,
2899 label: String,
2900 initial_value: String,
2901 ) -> u64 {
2902 let id = {
2903 let mut id_ref = self.next_request_id.borrow_mut();
2904 let id = *id_ref;
2905 *id_ref += 1;
2906 self.callback_contexts
2908 .borrow_mut()
2909 .insert(id, self.plugin_name.clone());
2910 id
2911 };
2912
2913 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2914 label,
2915 initial_value,
2916 callback_id: JsCallbackId::new(id),
2917 });
2918
2919 id
2920 }
2921
2922 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2924 self.command_sender
2925 .send(PluginCommand::StartPrompt { label, prompt_type })
2926 .is_ok()
2927 }
2928
2929 pub fn start_prompt_with_initial(
2931 &self,
2932 label: String,
2933 prompt_type: String,
2934 initial_value: String,
2935 ) -> bool {
2936 self.command_sender
2937 .send(PluginCommand::StartPromptWithInitial {
2938 label,
2939 prompt_type,
2940 initial_value,
2941 })
2942 .is_ok()
2943 }
2944
2945 pub fn set_prompt_suggestions(
2949 &self,
2950 suggestions: Vec<fresh_core::command::Suggestion>,
2951 ) -> bool {
2952 self.command_sender
2953 .send(PluginCommand::SetPromptSuggestions { suggestions })
2954 .is_ok()
2955 }
2956
2957 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2958 self.command_sender
2959 .send(PluginCommand::SetPromptInputSync { sync })
2960 .is_ok()
2961 }
2962
2963 pub fn define_mode(
2967 &self,
2968 name: String,
2969 bindings_arr: Vec<Vec<String>>,
2970 read_only: rquickjs::function::Opt<bool>,
2971 allow_text_input: rquickjs::function::Opt<bool>,
2972 inherit_normal_bindings: rquickjs::function::Opt<bool>,
2973 ) -> bool {
2974 let bindings: Vec<(String, String)> = bindings_arr
2975 .into_iter()
2976 .filter_map(|arr| {
2977 if arr.len() >= 2 {
2978 Some((arr[0].clone(), arr[1].clone()))
2979 } else {
2980 None
2981 }
2982 })
2983 .collect();
2984
2985 {
2988 let mut registered = self.registered_actions.borrow_mut();
2989 for (_, cmd_name) in &bindings {
2990 registered.insert(
2991 cmd_name.clone(),
2992 PluginHandler {
2993 plugin_name: self.plugin_name.clone(),
2994 handler_name: cmd_name.clone(),
2995 },
2996 );
2997 }
2998 }
2999
3000 let allow_text = allow_text_input.0.unwrap_or(false);
3003 if allow_text {
3004 let mut registered = self.registered_actions.borrow_mut();
3005 registered.insert(
3006 "mode_text_input".to_string(),
3007 PluginHandler {
3008 plugin_name: self.plugin_name.clone(),
3009 handler_name: "mode_text_input".to_string(),
3010 },
3011 );
3012 }
3013
3014 self.command_sender
3015 .send(PluginCommand::DefineMode {
3016 name,
3017 bindings,
3018 read_only: read_only.0.unwrap_or(false),
3019 allow_text_input: allow_text,
3020 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
3021 plugin_name: Some(self.plugin_name.clone()),
3022 })
3023 .is_ok()
3024 }
3025
3026 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
3028 self.command_sender
3029 .send(PluginCommand::SetEditorMode { mode })
3030 .is_ok()
3031 }
3032
3033 pub fn get_editor_mode(&self) -> Option<String> {
3035 self.state_snapshot
3036 .read()
3037 .ok()
3038 .and_then(|s| s.editor_mode.clone())
3039 }
3040
3041 pub fn close_split(&self, split_id: u32) -> bool {
3045 self.command_sender
3046 .send(PluginCommand::CloseSplit {
3047 split_id: SplitId(split_id as usize),
3048 })
3049 .is_ok()
3050 }
3051
3052 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
3054 self.command_sender
3055 .send(PluginCommand::SetSplitBuffer {
3056 split_id: SplitId(split_id as usize),
3057 buffer_id: BufferId(buffer_id as usize),
3058 })
3059 .is_ok()
3060 }
3061
3062 pub fn focus_split(&self, split_id: u32) -> bool {
3064 self.command_sender
3065 .send(PluginCommand::FocusSplit {
3066 split_id: SplitId(split_id as usize),
3067 })
3068 .is_ok()
3069 }
3070
3071 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
3073 self.command_sender
3074 .send(PluginCommand::SetSplitScroll {
3075 split_id: SplitId(split_id as usize),
3076 top_byte: top_byte as usize,
3077 })
3078 .is_ok()
3079 }
3080
3081 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
3083 self.command_sender
3084 .send(PluginCommand::SetSplitRatio {
3085 split_id: SplitId(split_id as usize),
3086 ratio,
3087 })
3088 .is_ok()
3089 }
3090
3091 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
3093 self.command_sender
3094 .send(PluginCommand::SetSplitLabel {
3095 split_id: SplitId(split_id as usize),
3096 label,
3097 })
3098 .is_ok()
3099 }
3100
3101 pub fn clear_split_label(&self, split_id: u32) -> bool {
3103 self.command_sender
3104 .send(PluginCommand::ClearSplitLabel {
3105 split_id: SplitId(split_id as usize),
3106 })
3107 .is_ok()
3108 }
3109
3110 #[plugin_api(
3112 async_promise,
3113 js_name = "getSplitByLabel",
3114 ts_return = "number | null"
3115 )]
3116 #[qjs(rename = "_getSplitByLabelStart")]
3117 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
3118 let id = {
3119 let mut id_ref = self.next_request_id.borrow_mut();
3120 let id = *id_ref;
3121 *id_ref += 1;
3122 self.callback_contexts
3123 .borrow_mut()
3124 .insert(id, self.plugin_name.clone());
3125 id
3126 };
3127 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
3128 label,
3129 request_id: id,
3130 });
3131 id
3132 }
3133
3134 pub fn distribute_splits_evenly(&self) -> bool {
3136 self.command_sender
3138 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
3139 .is_ok()
3140 }
3141
3142 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
3144 self.command_sender
3145 .send(PluginCommand::SetBufferCursor {
3146 buffer_id: BufferId(buffer_id as usize),
3147 position: position as usize,
3148 })
3149 .is_ok()
3150 }
3151
3152 #[qjs(rename = "setBufferShowCursors")]
3159 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
3160 self.command_sender
3161 .send(PluginCommand::SetBufferShowCursors {
3162 buffer_id: BufferId(buffer_id as usize),
3163 show,
3164 })
3165 .is_ok()
3166 }
3167
3168 #[allow(clippy::too_many_arguments)]
3172 pub fn set_line_indicator(
3173 &self,
3174 buffer_id: u32,
3175 line: u32,
3176 namespace: String,
3177 symbol: String,
3178 r: u8,
3179 g: u8,
3180 b: u8,
3181 priority: i32,
3182 ) -> bool {
3183 self.plugin_tracked_state
3185 .borrow_mut()
3186 .entry(self.plugin_name.clone())
3187 .or_default()
3188 .line_indicator_namespaces
3189 .push((BufferId(buffer_id as usize), namespace.clone()));
3190
3191 self.command_sender
3192 .send(PluginCommand::SetLineIndicator {
3193 buffer_id: BufferId(buffer_id as usize),
3194 line: line as usize,
3195 namespace,
3196 symbol,
3197 color: (r, g, b),
3198 priority,
3199 })
3200 .is_ok()
3201 }
3202
3203 #[allow(clippy::too_many_arguments)]
3205 pub fn set_line_indicators(
3206 &self,
3207 buffer_id: u32,
3208 lines: Vec<u32>,
3209 namespace: String,
3210 symbol: String,
3211 r: u8,
3212 g: u8,
3213 b: u8,
3214 priority: i32,
3215 ) -> bool {
3216 self.plugin_tracked_state
3218 .borrow_mut()
3219 .entry(self.plugin_name.clone())
3220 .or_default()
3221 .line_indicator_namespaces
3222 .push((BufferId(buffer_id as usize), namespace.clone()));
3223
3224 self.command_sender
3225 .send(PluginCommand::SetLineIndicators {
3226 buffer_id: BufferId(buffer_id as usize),
3227 lines: lines.into_iter().map(|l| l as usize).collect(),
3228 namespace,
3229 symbol,
3230 color: (r, g, b),
3231 priority,
3232 })
3233 .is_ok()
3234 }
3235
3236 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
3238 self.command_sender
3239 .send(PluginCommand::ClearLineIndicators {
3240 buffer_id: BufferId(buffer_id as usize),
3241 namespace,
3242 })
3243 .is_ok()
3244 }
3245
3246 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
3248 self.command_sender
3249 .send(PluginCommand::SetLineNumbers {
3250 buffer_id: BufferId(buffer_id as usize),
3251 enabled,
3252 })
3253 .is_ok()
3254 }
3255
3256 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
3258 self.command_sender
3259 .send(PluginCommand::SetViewMode {
3260 buffer_id: BufferId(buffer_id as usize),
3261 mode,
3262 })
3263 .is_ok()
3264 }
3265
3266 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
3268 self.command_sender
3269 .send(PluginCommand::SetLineWrap {
3270 buffer_id: BufferId(buffer_id as usize),
3271 split_id: split_id.map(|s| SplitId(s as usize)),
3272 enabled,
3273 })
3274 .is_ok()
3275 }
3276
3277 pub fn set_view_state<'js>(
3281 &self,
3282 ctx: rquickjs::Ctx<'js>,
3283 buffer_id: u32,
3284 key: String,
3285 value: Value<'js>,
3286 ) -> bool {
3287 let bid = BufferId(buffer_id as usize);
3288
3289 let json_value = if value.is_undefined() || value.is_null() {
3291 None
3292 } else {
3293 Some(js_to_json(&ctx, value))
3294 };
3295
3296 if let Ok(mut snapshot) = self.state_snapshot.write() {
3298 if let Some(ref json_val) = json_value {
3299 snapshot
3300 .plugin_view_states
3301 .entry(bid)
3302 .or_default()
3303 .insert(key.clone(), json_val.clone());
3304 } else {
3305 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
3307 map.remove(&key);
3308 if map.is_empty() {
3309 snapshot.plugin_view_states.remove(&bid);
3310 }
3311 }
3312 }
3313 }
3314
3315 self.command_sender
3317 .send(PluginCommand::SetViewState {
3318 buffer_id: bid,
3319 key,
3320 value: json_value,
3321 })
3322 .is_ok()
3323 }
3324
3325 pub fn get_view_state<'js>(
3327 &self,
3328 ctx: rquickjs::Ctx<'js>,
3329 buffer_id: u32,
3330 key: String,
3331 ) -> rquickjs::Result<Value<'js>> {
3332 let bid = BufferId(buffer_id as usize);
3333 if let Ok(snapshot) = self.state_snapshot.read() {
3334 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
3335 if let Some(json_val) = map.get(&key) {
3336 return json_to_js_value(&ctx, json_val);
3337 }
3338 }
3339 }
3340 Ok(Value::new_undefined(ctx.clone()))
3341 }
3342
3343 pub fn set_global_state<'js>(
3349 &self,
3350 ctx: rquickjs::Ctx<'js>,
3351 key: String,
3352 value: Value<'js>,
3353 ) -> bool {
3354 let json_value = if value.is_undefined() || value.is_null() {
3356 None
3357 } else {
3358 Some(js_to_json(&ctx, value))
3359 };
3360
3361 if let Ok(mut snapshot) = self.state_snapshot.write() {
3363 if let Some(ref json_val) = json_value {
3364 snapshot
3365 .plugin_global_states
3366 .entry(self.plugin_name.clone())
3367 .or_default()
3368 .insert(key.clone(), json_val.clone());
3369 } else {
3370 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3372 map.remove(&key);
3373 if map.is_empty() {
3374 snapshot.plugin_global_states.remove(&self.plugin_name);
3375 }
3376 }
3377 }
3378 }
3379
3380 self.command_sender
3382 .send(PluginCommand::SetGlobalState {
3383 plugin_name: self.plugin_name.clone(),
3384 key,
3385 value: json_value,
3386 })
3387 .is_ok()
3388 }
3389
3390 pub fn get_global_state<'js>(
3394 &self,
3395 ctx: rquickjs::Ctx<'js>,
3396 key: String,
3397 ) -> rquickjs::Result<Value<'js>> {
3398 if let Ok(snapshot) = self.state_snapshot.read() {
3399 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3400 if let Some(json_val) = map.get(&key) {
3401 return json_to_js_value(&ctx, json_val);
3402 }
3403 }
3404 }
3405 Ok(Value::new_undefined(ctx.clone()))
3406 }
3407
3408 pub fn create_scroll_sync_group(
3412 &self,
3413 group_id: u32,
3414 left_split: u32,
3415 right_split: u32,
3416 ) -> bool {
3417 self.plugin_tracked_state
3419 .borrow_mut()
3420 .entry(self.plugin_name.clone())
3421 .or_default()
3422 .scroll_sync_group_ids
3423 .push(group_id);
3424 self.command_sender
3425 .send(PluginCommand::CreateScrollSyncGroup {
3426 group_id,
3427 left_split: SplitId(left_split as usize),
3428 right_split: SplitId(right_split as usize),
3429 })
3430 .is_ok()
3431 }
3432
3433 pub fn set_scroll_sync_anchors<'js>(
3435 &self,
3436 _ctx: rquickjs::Ctx<'js>,
3437 group_id: u32,
3438 anchors: Vec<Vec<u32>>,
3439 ) -> bool {
3440 let anchors: Vec<(usize, usize)> = anchors
3441 .into_iter()
3442 .filter_map(|pair| {
3443 if pair.len() >= 2 {
3444 Some((pair[0] as usize, pair[1] as usize))
3445 } else {
3446 None
3447 }
3448 })
3449 .collect();
3450 self.command_sender
3451 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3452 .is_ok()
3453 }
3454
3455 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3457 self.command_sender
3458 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3459 .is_ok()
3460 }
3461
3462 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3468 self.command_sender
3469 .send(PluginCommand::ExecuteActions { actions })
3470 .is_ok()
3471 }
3472
3473 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3477 self.command_sender
3478 .send(PluginCommand::ShowActionPopup {
3479 popup_id: opts.id,
3480 title: opts.title,
3481 message: opts.message,
3482 actions: opts.actions,
3483 })
3484 .is_ok()
3485 }
3486
3487 pub fn disable_lsp_for_language(&self, language: String) -> bool {
3489 self.command_sender
3490 .send(PluginCommand::DisableLspForLanguage { language })
3491 .is_ok()
3492 }
3493
3494 pub fn restart_lsp_for_language(&self, language: String) -> bool {
3496 self.command_sender
3497 .send(PluginCommand::RestartLspForLanguage { language })
3498 .is_ok()
3499 }
3500
3501 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3504 self.command_sender
3505 .send(PluginCommand::SetLspRootUri { language, uri })
3506 .is_ok()
3507 }
3508
3509 #[plugin_api(ts_return = "JsDiagnostic[]")]
3511 pub fn get_all_diagnostics<'js>(
3512 &self,
3513 ctx: rquickjs::Ctx<'js>,
3514 ) -> rquickjs::Result<Value<'js>> {
3515 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3516
3517 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3518 let mut result: Vec<JsDiagnostic> = Vec::new();
3520 for (uri, diags) in s.diagnostics.iter() {
3521 for diag in diags {
3522 result.push(JsDiagnostic {
3523 uri: uri.clone(),
3524 message: diag.message.clone(),
3525 severity: diag.severity.map(|s| match s {
3526 lsp_types::DiagnosticSeverity::ERROR => 1,
3527 lsp_types::DiagnosticSeverity::WARNING => 2,
3528 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3529 lsp_types::DiagnosticSeverity::HINT => 4,
3530 _ => 0,
3531 }),
3532 range: JsRange {
3533 start: JsPosition {
3534 line: diag.range.start.line,
3535 character: diag.range.start.character,
3536 },
3537 end: JsPosition {
3538 line: diag.range.end.line,
3539 character: diag.range.end.character,
3540 },
3541 },
3542 source: diag.source.clone(),
3543 });
3544 }
3545 }
3546 result
3547 } else {
3548 Vec::new()
3549 };
3550 rquickjs_serde::to_value(ctx, &diagnostics)
3551 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3552 }
3553
3554 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3556 self.event_handlers
3557 .borrow()
3558 .get(&event_name)
3559 .cloned()
3560 .unwrap_or_default()
3561 .into_iter()
3562 .map(|h| h.handler_name)
3563 .collect()
3564 }
3565
3566 #[plugin_api(
3570 async_promise,
3571 js_name = "createVirtualBuffer",
3572 ts_return = "VirtualBufferResult"
3573 )]
3574 #[qjs(rename = "_createVirtualBufferStart")]
3575 pub fn create_virtual_buffer_start(
3576 &self,
3577 _ctx: rquickjs::Ctx<'_>,
3578 opts: fresh_core::api::CreateVirtualBufferOptions,
3579 ) -> rquickjs::Result<u64> {
3580 let id = {
3581 let mut id_ref = self.next_request_id.borrow_mut();
3582 let id = *id_ref;
3583 *id_ref += 1;
3584 self.callback_contexts
3586 .borrow_mut()
3587 .insert(id, self.plugin_name.clone());
3588 id
3589 };
3590
3591 let entries: Vec<TextPropertyEntry> = opts
3593 .entries
3594 .unwrap_or_default()
3595 .into_iter()
3596 .map(|e| TextPropertyEntry {
3597 text: e.text,
3598 properties: e.properties.unwrap_or_default(),
3599 style: e.style,
3600 inline_overlays: e.inline_overlays.unwrap_or_default(),
3601 })
3602 .collect();
3603
3604 tracing::debug!(
3605 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3606 id
3607 );
3608 if let Ok(mut owners) = self.async_resource_owners.lock() {
3610 owners.insert(id, self.plugin_name.clone());
3611 }
3612 let _ = self
3613 .command_sender
3614 .send(PluginCommand::CreateVirtualBufferWithContent {
3615 name: opts.name,
3616 mode: opts.mode.unwrap_or_default(),
3617 read_only: opts.read_only.unwrap_or(false),
3618 entries,
3619 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3620 show_cursors: opts.show_cursors.unwrap_or(true),
3621 editing_disabled: opts.editing_disabled.unwrap_or(false),
3622 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3623 request_id: Some(id),
3624 });
3625 Ok(id)
3626 }
3627
3628 #[plugin_api(
3630 async_promise,
3631 js_name = "createVirtualBufferInSplit",
3632 ts_return = "VirtualBufferResult"
3633 )]
3634 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3635 pub fn create_virtual_buffer_in_split_start(
3636 &self,
3637 _ctx: rquickjs::Ctx<'_>,
3638 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3639 ) -> rquickjs::Result<u64> {
3640 let id = {
3641 let mut id_ref = self.next_request_id.borrow_mut();
3642 let id = *id_ref;
3643 *id_ref += 1;
3644 self.callback_contexts
3646 .borrow_mut()
3647 .insert(id, self.plugin_name.clone());
3648 id
3649 };
3650
3651 let entries: Vec<TextPropertyEntry> = opts
3653 .entries
3654 .unwrap_or_default()
3655 .into_iter()
3656 .map(|e| TextPropertyEntry {
3657 text: e.text,
3658 properties: e.properties.unwrap_or_default(),
3659 style: e.style,
3660 inline_overlays: e.inline_overlays.unwrap_or_default(),
3661 })
3662 .collect();
3663
3664 if let Ok(mut owners) = self.async_resource_owners.lock() {
3666 owners.insert(id, self.plugin_name.clone());
3667 }
3668 let _ = self
3669 .command_sender
3670 .send(PluginCommand::CreateVirtualBufferInSplit {
3671 name: opts.name,
3672 mode: opts.mode.unwrap_or_default(),
3673 read_only: opts.read_only.unwrap_or(false),
3674 entries,
3675 ratio: opts.ratio.unwrap_or(0.5),
3676 direction: opts.direction,
3677 panel_id: opts.panel_id,
3678 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3679 show_cursors: opts.show_cursors.unwrap_or(true),
3680 editing_disabled: opts.editing_disabled.unwrap_or(false),
3681 line_wrap: opts.line_wrap,
3682 before: opts.before.unwrap_or(false),
3683 request_id: Some(id),
3684 });
3685 Ok(id)
3686 }
3687
3688 #[plugin_api(
3690 async_promise,
3691 js_name = "createVirtualBufferInExistingSplit",
3692 ts_return = "VirtualBufferResult"
3693 )]
3694 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3695 pub fn create_virtual_buffer_in_existing_split_start(
3696 &self,
3697 _ctx: rquickjs::Ctx<'_>,
3698 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3699 ) -> rquickjs::Result<u64> {
3700 let id = {
3701 let mut id_ref = self.next_request_id.borrow_mut();
3702 let id = *id_ref;
3703 *id_ref += 1;
3704 self.callback_contexts
3706 .borrow_mut()
3707 .insert(id, self.plugin_name.clone());
3708 id
3709 };
3710
3711 let entries: Vec<TextPropertyEntry> = opts
3713 .entries
3714 .unwrap_or_default()
3715 .into_iter()
3716 .map(|e| TextPropertyEntry {
3717 text: e.text,
3718 properties: e.properties.unwrap_or_default(),
3719 style: e.style,
3720 inline_overlays: e.inline_overlays.unwrap_or_default(),
3721 })
3722 .collect();
3723
3724 if let Ok(mut owners) = self.async_resource_owners.lock() {
3726 owners.insert(id, self.plugin_name.clone());
3727 }
3728 let _ = self
3729 .command_sender
3730 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3731 name: opts.name,
3732 mode: opts.mode.unwrap_or_default(),
3733 read_only: opts.read_only.unwrap_or(false),
3734 entries,
3735 split_id: SplitId(opts.split_id),
3736 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3737 show_cursors: opts.show_cursors.unwrap_or(true),
3738 editing_disabled: opts.editing_disabled.unwrap_or(false),
3739 line_wrap: opts.line_wrap,
3740 request_id: Some(id),
3741 });
3742 Ok(id)
3743 }
3744
3745 #[qjs(rename = "_createBufferGroupStart")]
3747 pub fn create_buffer_group_start(
3748 &self,
3749 _ctx: rquickjs::Ctx<'_>,
3750 name: String,
3751 mode: String,
3752 layout_json: String,
3753 ) -> rquickjs::Result<u64> {
3754 let id = {
3755 let mut id_ref = self.next_request_id.borrow_mut();
3756 let id = *id_ref;
3757 *id_ref += 1;
3758 self.callback_contexts
3759 .borrow_mut()
3760 .insert(id, self.plugin_name.clone());
3761 id
3762 };
3763 if let Ok(mut owners) = self.async_resource_owners.lock() {
3764 owners.insert(id, self.plugin_name.clone());
3765 }
3766 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
3767 name,
3768 mode,
3769 layout_json,
3770 request_id: Some(id),
3771 });
3772 Ok(id)
3773 }
3774
3775 #[qjs(rename = "setPanelContent")]
3777 pub fn set_panel_content<'js>(
3778 &self,
3779 ctx: rquickjs::Ctx<'js>,
3780 group_id: u32,
3781 panel_name: String,
3782 entries_arr: Vec<rquickjs::Object<'js>>,
3783 ) -> rquickjs::Result<bool> {
3784 let entries: Vec<TextPropertyEntry> = entries_arr
3785 .iter()
3786 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3787 .collect();
3788 Ok(self
3789 .command_sender
3790 .send(PluginCommand::SetPanelContent {
3791 group_id: group_id as usize,
3792 panel_name,
3793 entries,
3794 })
3795 .is_ok())
3796 }
3797
3798 #[qjs(rename = "closeBufferGroup")]
3800 pub fn close_buffer_group(&self, group_id: u32) -> bool {
3801 self.command_sender
3802 .send(PluginCommand::CloseBufferGroup {
3803 group_id: group_id as usize,
3804 })
3805 .is_ok()
3806 }
3807
3808 #[qjs(rename = "focusBufferGroupPanel")]
3810 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
3811 self.command_sender
3812 .send(PluginCommand::FocusPanel {
3813 group_id: group_id as usize,
3814 panel_name,
3815 })
3816 .is_ok()
3817 }
3818
3819 pub fn set_virtual_buffer_content<'js>(
3823 &self,
3824 ctx: rquickjs::Ctx<'js>,
3825 buffer_id: u32,
3826 entries_arr: Vec<rquickjs::Object<'js>>,
3827 ) -> rquickjs::Result<bool> {
3828 let entries: Vec<TextPropertyEntry> = entries_arr
3829 .iter()
3830 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3831 .collect();
3832 Ok(self
3833 .command_sender
3834 .send(PluginCommand::SetVirtualBufferContent {
3835 buffer_id: BufferId(buffer_id as usize),
3836 entries,
3837 })
3838 .is_ok())
3839 }
3840
3841 pub fn get_text_properties_at_cursor(
3843 &self,
3844 buffer_id: u32,
3845 ) -> fresh_core::api::TextPropertiesAtCursor {
3846 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3847 }
3848
3849 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3853 #[qjs(rename = "_spawnProcessStart")]
3854 pub fn spawn_process_start(
3855 &self,
3856 _ctx: rquickjs::Ctx<'_>,
3857 command: String,
3858 args: Vec<String>,
3859 cwd: rquickjs::function::Opt<String>,
3860 ) -> u64 {
3861 let id = {
3862 let mut id_ref = self.next_request_id.borrow_mut();
3863 let id = *id_ref;
3864 *id_ref += 1;
3865 self.callback_contexts
3867 .borrow_mut()
3868 .insert(id, self.plugin_name.clone());
3869 id
3870 };
3871 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
3877 self.state_snapshot
3878 .read()
3879 .ok()
3880 .map(|s| s.working_dir.to_string_lossy().to_string())
3881 });
3882 tracing::info!(
3883 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3884 self.plugin_name,
3885 command,
3886 args,
3887 effective_cwd,
3888 id
3889 );
3890 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3891 callback_id: JsCallbackId::new(id),
3892 command,
3893 args,
3894 cwd: effective_cwd,
3895 });
3896 id
3897 }
3898
3899 #[plugin_api(
3906 async_thenable,
3907 js_name = "spawnHostProcess",
3908 ts_return = "SpawnResult"
3909 )]
3910 #[qjs(rename = "_spawnHostProcessStart")]
3911 pub fn spawn_host_process_start(
3912 &self,
3913 _ctx: rquickjs::Ctx<'_>,
3914 command: String,
3915 args: Vec<String>,
3916 cwd: rquickjs::function::Opt<String>,
3917 ) -> u64 {
3918 let id = {
3919 let mut id_ref = self.next_request_id.borrow_mut();
3920 let id = *id_ref;
3921 *id_ref += 1;
3922 self.callback_contexts
3923 .borrow_mut()
3924 .insert(id, self.plugin_name.clone());
3925 id
3926 };
3927 let effective_cwd = cwd.0.or_else(|| {
3928 self.state_snapshot
3929 .read()
3930 .ok()
3931 .map(|s| s.working_dir.to_string_lossy().to_string())
3932 });
3933 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
3934 callback_id: JsCallbackId::new(id),
3935 command,
3936 args,
3937 cwd: effective_cwd,
3938 });
3939 id
3940 }
3941
3942 #[plugin_api(js_name = "_killHostProcess")]
3952 pub fn kill_host_process(&self, process_id: u64) -> bool {
3953 self.command_sender
3954 .send(PluginCommand::KillHostProcess { process_id })
3955 .is_ok()
3956 }
3957
3958 #[plugin_api(js_name = "setAuthority")]
3967 pub fn set_authority(
3968 &self,
3969 ctx: rquickjs::Ctx<'_>,
3970 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
3971 ) -> bool {
3972 let json = js_to_json(&ctx, payload);
3973 let _ = self
3974 .command_sender
3975 .send(PluginCommand::SetAuthority { payload: json });
3976 true
3977 }
3978
3979 #[plugin_api(js_name = "clearAuthority")]
3982 pub fn clear_authority(&self) {
3983 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
3984 }
3985
3986 #[plugin_api(js_name = "setRemoteIndicatorState")]
4004 pub fn set_remote_indicator_state(
4005 &self,
4006 ctx: rquickjs::Ctx<'_>,
4007 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
4008 ) -> bool {
4009 let json = js_to_json(&ctx, state);
4010 let _ = self
4011 .command_sender
4012 .send(PluginCommand::SetRemoteIndicatorState { state: json });
4013 true
4014 }
4015
4016 #[plugin_api(js_name = "clearRemoteIndicatorState")]
4019 pub fn clear_remote_indicator_state(&self) {
4020 let _ = self
4021 .command_sender
4022 .send(PluginCommand::ClearRemoteIndicatorState);
4023 }
4024
4025 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
4027 #[qjs(rename = "_spawnProcessWaitStart")]
4028 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
4029 let id = {
4030 let mut id_ref = self.next_request_id.borrow_mut();
4031 let id = *id_ref;
4032 *id_ref += 1;
4033 self.callback_contexts
4035 .borrow_mut()
4036 .insert(id, self.plugin_name.clone());
4037 id
4038 };
4039 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
4040 process_id,
4041 callback_id: JsCallbackId::new(id),
4042 });
4043 id
4044 }
4045
4046 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
4048 #[qjs(rename = "_getBufferTextStart")]
4049 pub fn get_buffer_text_start(
4050 &self,
4051 _ctx: rquickjs::Ctx<'_>,
4052 buffer_id: u32,
4053 start: u32,
4054 end: u32,
4055 ) -> u64 {
4056 let id = {
4057 let mut id_ref = self.next_request_id.borrow_mut();
4058 let id = *id_ref;
4059 *id_ref += 1;
4060 self.callback_contexts
4062 .borrow_mut()
4063 .insert(id, self.plugin_name.clone());
4064 id
4065 };
4066 let _ = self.command_sender.send(PluginCommand::GetBufferText {
4067 buffer_id: BufferId(buffer_id as usize),
4068 start: start as usize,
4069 end: end as usize,
4070 request_id: id,
4071 });
4072 id
4073 }
4074
4075 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
4077 #[qjs(rename = "_delayStart")]
4078 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
4079 let id = {
4080 let mut id_ref = self.next_request_id.borrow_mut();
4081 let id = *id_ref;
4082 *id_ref += 1;
4083 self.callback_contexts
4085 .borrow_mut()
4086 .insert(id, self.plugin_name.clone());
4087 id
4088 };
4089 let _ = self.command_sender.send(PluginCommand::Delay {
4090 callback_id: JsCallbackId::new(id),
4091 duration_ms,
4092 });
4093 id
4094 }
4095
4096 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
4100 #[qjs(rename = "_grepProjectStart")]
4101 pub fn grep_project_start(
4102 &self,
4103 _ctx: rquickjs::Ctx<'_>,
4104 pattern: String,
4105 fixed_string: Option<bool>,
4106 case_sensitive: Option<bool>,
4107 max_results: Option<u32>,
4108 whole_words: Option<bool>,
4109 ) -> u64 {
4110 let id = {
4111 let mut id_ref = self.next_request_id.borrow_mut();
4112 let id = *id_ref;
4113 *id_ref += 1;
4114 self.callback_contexts
4115 .borrow_mut()
4116 .insert(id, self.plugin_name.clone());
4117 id
4118 };
4119 let _ = self.command_sender.send(PluginCommand::GrepProject {
4120 pattern,
4121 fixed_string: fixed_string.unwrap_or(true),
4122 case_sensitive: case_sensitive.unwrap_or(true),
4123 max_results: max_results.unwrap_or(200) as usize,
4124 whole_words: whole_words.unwrap_or(false),
4125 callback_id: JsCallbackId::new(id),
4126 });
4127 id
4128 }
4129
4130 #[plugin_api(
4134 js_name = "grepProjectStreaming",
4135 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
4136 )]
4137 #[qjs(rename = "_grepProjectStreamingStart")]
4138 pub fn grep_project_streaming_start(
4139 &self,
4140 _ctx: rquickjs::Ctx<'_>,
4141 pattern: String,
4142 fixed_string: bool,
4143 case_sensitive: bool,
4144 max_results: u32,
4145 whole_words: bool,
4146 ) -> u64 {
4147 let id = {
4148 let mut id_ref = self.next_request_id.borrow_mut();
4149 let id = *id_ref;
4150 *id_ref += 1;
4151 self.callback_contexts
4152 .borrow_mut()
4153 .insert(id, self.plugin_name.clone());
4154 id
4155 };
4156 let _ = self
4157 .command_sender
4158 .send(PluginCommand::GrepProjectStreaming {
4159 pattern,
4160 fixed_string,
4161 case_sensitive,
4162 max_results: max_results as usize,
4163 whole_words,
4164 search_id: id,
4165 callback_id: JsCallbackId::new(id),
4166 });
4167 id
4168 }
4169
4170 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
4174 #[qjs(rename = "_replaceInFileStart")]
4175 pub fn replace_in_file_start(
4176 &self,
4177 _ctx: rquickjs::Ctx<'_>,
4178 file_path: String,
4179 matches: Vec<Vec<u32>>,
4180 replacement: String,
4181 ) -> u64 {
4182 let id = {
4183 let mut id_ref = self.next_request_id.borrow_mut();
4184 let id = *id_ref;
4185 *id_ref += 1;
4186 self.callback_contexts
4187 .borrow_mut()
4188 .insert(id, self.plugin_name.clone());
4189 id
4190 };
4191 let match_pairs: Vec<(usize, usize)> = matches
4193 .iter()
4194 .map(|m| (m[0] as usize, m[1] as usize))
4195 .collect();
4196 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
4197 file_path: PathBuf::from(file_path),
4198 matches: match_pairs,
4199 replacement,
4200 callback_id: JsCallbackId::new(id),
4201 });
4202 id
4203 }
4204
4205 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
4207 #[qjs(rename = "_sendLspRequestStart")]
4208 pub fn send_lsp_request_start<'js>(
4209 &self,
4210 ctx: rquickjs::Ctx<'js>,
4211 language: String,
4212 method: String,
4213 params: Option<rquickjs::Object<'js>>,
4214 ) -> rquickjs::Result<u64> {
4215 let id = {
4216 let mut id_ref = self.next_request_id.borrow_mut();
4217 let id = *id_ref;
4218 *id_ref += 1;
4219 self.callback_contexts
4221 .borrow_mut()
4222 .insert(id, self.plugin_name.clone());
4223 id
4224 };
4225 let params_json: Option<serde_json::Value> = params.map(|obj| {
4227 let val = obj.into_value();
4228 js_to_json(&ctx, val)
4229 });
4230 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
4231 request_id: id,
4232 language,
4233 method,
4234 params: params_json,
4235 });
4236 Ok(id)
4237 }
4238
4239 #[plugin_api(
4241 async_thenable,
4242 js_name = "spawnBackgroundProcess",
4243 ts_return = "BackgroundProcessResult"
4244 )]
4245 #[qjs(rename = "_spawnBackgroundProcessStart")]
4246 pub fn spawn_background_process_start(
4247 &self,
4248 _ctx: rquickjs::Ctx<'_>,
4249 command: String,
4250 args: Vec<String>,
4251 cwd: rquickjs::function::Opt<String>,
4252 ) -> u64 {
4253 let id = {
4254 let mut id_ref = self.next_request_id.borrow_mut();
4255 let id = *id_ref;
4256 *id_ref += 1;
4257 self.callback_contexts
4259 .borrow_mut()
4260 .insert(id, self.plugin_name.clone());
4261 id
4262 };
4263 let process_id = id;
4265 self.plugin_tracked_state
4267 .borrow_mut()
4268 .entry(self.plugin_name.clone())
4269 .or_default()
4270 .background_process_ids
4271 .push(process_id);
4272 let _ = self
4274 .command_sender
4275 .send(PluginCommand::SpawnBackgroundProcess {
4276 process_id,
4277 command,
4278 args,
4279 cwd: cwd.0.filter(|s| !s.is_empty()),
4280 callback_id: JsCallbackId::new(id),
4281 });
4282 id
4283 }
4284
4285 pub fn kill_background_process(&self, process_id: u64) -> bool {
4287 self.command_sender
4288 .send(PluginCommand::KillBackgroundProcess { process_id })
4289 .is_ok()
4290 }
4291
4292 #[plugin_api(
4296 async_promise,
4297 js_name = "createTerminal",
4298 ts_return = "TerminalResult"
4299 )]
4300 #[qjs(rename = "_createTerminalStart")]
4301 pub fn create_terminal_start(
4302 &self,
4303 _ctx: rquickjs::Ctx<'_>,
4304 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
4305 ) -> rquickjs::Result<u64> {
4306 let id = {
4307 let mut id_ref = self.next_request_id.borrow_mut();
4308 let id = *id_ref;
4309 *id_ref += 1;
4310 self.callback_contexts
4311 .borrow_mut()
4312 .insert(id, self.plugin_name.clone());
4313 id
4314 };
4315
4316 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
4317 cwd: None,
4318 direction: None,
4319 ratio: None,
4320 focus: None,
4321 persistent: None,
4322 });
4323
4324 if let Ok(mut owners) = self.async_resource_owners.lock() {
4326 owners.insert(id, self.plugin_name.clone());
4327 }
4328 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
4329 cwd: opts.cwd,
4330 direction: opts.direction,
4331 ratio: opts.ratio,
4332 focus: opts.focus,
4333 persistent: opts.persistent.unwrap_or(false),
4337 request_id: id,
4338 });
4339 Ok(id)
4340 }
4341
4342 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
4344 self.command_sender
4345 .send(PluginCommand::SendTerminalInput {
4346 terminal_id: fresh_core::TerminalId(terminal_id as usize),
4347 data,
4348 })
4349 .is_ok()
4350 }
4351
4352 pub fn close_terminal(&self, terminal_id: u64) -> bool {
4354 self.command_sender
4355 .send(PluginCommand::CloseTerminal {
4356 terminal_id: fresh_core::TerminalId(terminal_id as usize),
4357 })
4358 .is_ok()
4359 }
4360
4361 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
4365 self.command_sender
4366 .send(PluginCommand::RefreshLines {
4367 buffer_id: BufferId(buffer_id as usize),
4368 })
4369 .is_ok()
4370 }
4371
4372 pub fn get_current_locale(&self) -> String {
4374 self.services.current_locale()
4375 }
4376
4377 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
4381 #[qjs(rename = "_loadPluginStart")]
4382 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
4383 let id = {
4384 let mut id_ref = self.next_request_id.borrow_mut();
4385 let id = *id_ref;
4386 *id_ref += 1;
4387 self.callback_contexts
4388 .borrow_mut()
4389 .insert(id, self.plugin_name.clone());
4390 id
4391 };
4392 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
4393 path: std::path::PathBuf::from(path),
4394 callback_id: JsCallbackId::new(id),
4395 });
4396 id
4397 }
4398
4399 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
4401 #[qjs(rename = "_unloadPluginStart")]
4402 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4403 let id = {
4404 let mut id_ref = self.next_request_id.borrow_mut();
4405 let id = *id_ref;
4406 *id_ref += 1;
4407 self.callback_contexts
4408 .borrow_mut()
4409 .insert(id, self.plugin_name.clone());
4410 id
4411 };
4412 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
4413 name,
4414 callback_id: JsCallbackId::new(id),
4415 });
4416 id
4417 }
4418
4419 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
4421 #[qjs(rename = "_reloadPluginStart")]
4422 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4423 let id = {
4424 let mut id_ref = self.next_request_id.borrow_mut();
4425 let id = *id_ref;
4426 *id_ref += 1;
4427 self.callback_contexts
4428 .borrow_mut()
4429 .insert(id, self.plugin_name.clone());
4430 id
4431 };
4432 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
4433 name,
4434 callback_id: JsCallbackId::new(id),
4435 });
4436 id
4437 }
4438
4439 #[plugin_api(
4442 async_promise,
4443 js_name = "listPlugins",
4444 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
4445 )]
4446 #[qjs(rename = "_listPluginsStart")]
4447 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4448 let id = {
4449 let mut id_ref = self.next_request_id.borrow_mut();
4450 let id = *id_ref;
4451 *id_ref += 1;
4452 self.callback_contexts
4453 .borrow_mut()
4454 .insert(id, self.plugin_name.clone());
4455 id
4456 };
4457 let _ = self.command_sender.send(PluginCommand::ListPlugins {
4458 callback_id: JsCallbackId::new(id),
4459 });
4460 id
4461 }
4462}
4463
4464fn parse_view_token(
4471 obj: &rquickjs::Object<'_>,
4472 idx: usize,
4473) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
4474 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4475
4476 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
4478 from: "object",
4479 to: "ViewTokenWire",
4480 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
4481 })?;
4482
4483 let source_offset: Option<usize> = obj
4485 .get("sourceOffset")
4486 .ok()
4487 .or_else(|| obj.get("source_offset").ok());
4488
4489 let kind = if kind_value.is_string() {
4491 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4494 from: "value",
4495 to: "string",
4496 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
4497 })?;
4498
4499 match kind_str.to_lowercase().as_str() {
4500 "text" => {
4501 let text: String = obj.get("text").unwrap_or_default();
4502 ViewTokenWireKind::Text(text)
4503 }
4504 "newline" => ViewTokenWireKind::Newline,
4505 "space" => ViewTokenWireKind::Space,
4506 "break" => ViewTokenWireKind::Break,
4507 _ => {
4508 tracing::warn!(
4510 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
4511 idx, kind_str
4512 );
4513 return Err(rquickjs::Error::FromJs {
4514 from: "string",
4515 to: "ViewTokenWireKind",
4516 message: Some(format!(
4517 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
4518 idx, kind_str
4519 )),
4520 });
4521 }
4522 }
4523 } else if kind_value.is_object() {
4524 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4526 from: "value",
4527 to: "object",
4528 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
4529 })?;
4530
4531 if let Ok(text) = kind_obj.get::<_, String>("Text") {
4532 ViewTokenWireKind::Text(text)
4533 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
4534 ViewTokenWireKind::BinaryByte(byte)
4535 } else {
4536 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
4538 tracing::warn!(
4539 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
4540 idx,
4541 keys
4542 );
4543 return Err(rquickjs::Error::FromJs {
4544 from: "object",
4545 to: "ViewTokenWireKind",
4546 message: Some(format!(
4547 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
4548 idx, keys
4549 )),
4550 });
4551 }
4552 } else {
4553 tracing::warn!(
4554 "token[{}]: 'kind' field must be a string or object, got: {:?}",
4555 idx,
4556 kind_value.type_of()
4557 );
4558 return Err(rquickjs::Error::FromJs {
4559 from: "value",
4560 to: "ViewTokenWireKind",
4561 message: Some(format!(
4562 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
4563 idx
4564 )),
4565 });
4566 };
4567
4568 let style = parse_view_token_style(obj, idx)?;
4570
4571 Ok(ViewTokenWire {
4572 source_offset,
4573 kind,
4574 style,
4575 })
4576}
4577
4578fn parse_view_token_style(
4580 obj: &rquickjs::Object<'_>,
4581 idx: usize,
4582) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
4583 use fresh_core::api::ViewTokenStyle;
4584
4585 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
4586 let Some(s) = style_obj else {
4587 return Ok(None);
4588 };
4589
4590 let fg: Option<Vec<u8>> = s.get("fg").ok();
4591 let bg: Option<Vec<u8>> = s.get("bg").ok();
4592
4593 let fg_color = if let Some(ref c) = fg {
4595 if c.len() < 3 {
4596 tracing::warn!(
4597 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4598 idx,
4599 c.len()
4600 );
4601 None
4602 } else {
4603 Some((c[0], c[1], c[2]))
4604 }
4605 } else {
4606 None
4607 };
4608
4609 let bg_color = if let Some(ref c) = bg {
4610 if c.len() < 3 {
4611 tracing::warn!(
4612 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4613 idx,
4614 c.len()
4615 );
4616 None
4617 } else {
4618 Some((c[0], c[1], c[2]))
4619 }
4620 } else {
4621 None
4622 };
4623
4624 Ok(Some(ViewTokenStyle {
4625 fg: fg_color,
4626 bg: bg_color,
4627 bold: s.get("bold").unwrap_or(false),
4628 italic: s.get("italic").unwrap_or(false),
4629 }))
4630}
4631
4632pub struct QuickJsBackend {
4634 runtime: Runtime,
4635 main_context: Context,
4637 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4639 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4641 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4643 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4645 command_sender: mpsc::Sender<PluginCommand>,
4647 #[allow(dead_code)]
4649 pending_responses: PendingResponses,
4650 next_request_id: Rc<RefCell<u64>>,
4652 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4654 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4656 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4658 async_resource_owners: AsyncResourceOwners,
4661 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4663 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4665 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4667 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4669 plugin_api_exports:
4673 Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>,
4674}
4675
4676impl Drop for QuickJsBackend {
4677 fn drop(&mut self) {
4678 self.plugin_api_exports.borrow_mut().clear();
4684 }
4685}
4686
4687impl QuickJsBackend {
4688 pub fn new() -> Result<Self> {
4690 let (tx, _rx) = mpsc::channel();
4691 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4692 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4693 Self::with_state(state_snapshot, tx, services)
4694 }
4695
4696 pub fn with_state(
4698 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4699 command_sender: mpsc::Sender<PluginCommand>,
4700 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4701 ) -> Result<Self> {
4702 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4703 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4704 }
4705
4706 pub fn with_state_and_responses(
4708 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4709 command_sender: mpsc::Sender<PluginCommand>,
4710 pending_responses: PendingResponses,
4711 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4712 ) -> Result<Self> {
4713 let async_resource_owners: AsyncResourceOwners =
4714 Arc::new(std::sync::Mutex::new(HashMap::new()));
4715 Self::with_state_responses_and_resources(
4716 state_snapshot,
4717 command_sender,
4718 pending_responses,
4719 services,
4720 async_resource_owners,
4721 )
4722 }
4723
4724 pub fn with_state_responses_and_resources(
4727 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4728 command_sender: mpsc::Sender<PluginCommand>,
4729 pending_responses: PendingResponses,
4730 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4731 async_resource_owners: AsyncResourceOwners,
4732 ) -> Result<Self> {
4733 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4734
4735 let runtime =
4736 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4737
4738 runtime.set_host_promise_rejection_tracker(Some(Box::new(
4740 |_ctx, _promise, reason, is_handled| {
4741 if !is_handled {
4742 let error_msg = if let Some(exc) = reason.as_exception() {
4744 format!(
4745 "{}: {}",
4746 exc.message().unwrap_or_default(),
4747 exc.stack().unwrap_or_default()
4748 )
4749 } else {
4750 format!("{:?}", reason)
4751 };
4752
4753 tracing::error!("Unhandled Promise rejection: {}", error_msg);
4754
4755 if should_panic_on_js_errors() {
4756 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4759 set_fatal_js_error(full_msg);
4760 }
4761 }
4762 },
4763 )));
4764
4765 let main_context = Context::full(&runtime)
4766 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4767
4768 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4769 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4770 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4771 let next_request_id = Rc::new(RefCell::new(1u64));
4772 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4773 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4774 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4775 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4776 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4777 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4778 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
4779
4780 let backend = Self {
4781 runtime,
4782 main_context,
4783 plugin_contexts,
4784 event_handlers,
4785 registered_actions,
4786 state_snapshot,
4787 command_sender,
4788 pending_responses,
4789 next_request_id,
4790 callback_contexts,
4791 services,
4792 plugin_tracked_state,
4793 async_resource_owners,
4794 registered_command_names,
4795 registered_grammar_languages,
4796 registered_language_configs,
4797 registered_lsp_servers,
4798 plugin_api_exports,
4799 };
4800
4801 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4803
4804 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4805 Ok(backend)
4806 }
4807
4808 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4810 let state_snapshot = Arc::clone(&self.state_snapshot);
4811 let command_sender = self.command_sender.clone();
4812 let event_handlers = Rc::clone(&self.event_handlers);
4813 let registered_actions = Rc::clone(&self.registered_actions);
4814 let next_request_id = Rc::clone(&self.next_request_id);
4815 let registered_command_names = Rc::clone(&self.registered_command_names);
4816 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4817 let registered_language_configs = Rc::clone(&self.registered_language_configs);
4818 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4819 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
4820
4821 context.with(|ctx| {
4822 let globals = ctx.globals();
4823
4824 globals.set("__pluginName__", plugin_name)?;
4826
4827 let js_api = JsEditorApi {
4830 state_snapshot: Arc::clone(&state_snapshot),
4831 command_sender: command_sender.clone(),
4832 registered_actions: Rc::clone(®istered_actions),
4833 event_handlers: Rc::clone(&event_handlers),
4834 next_request_id: Rc::clone(&next_request_id),
4835 callback_contexts: Rc::clone(&self.callback_contexts),
4836 services: self.services.clone(),
4837 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4838 async_resource_owners: Arc::clone(&self.async_resource_owners),
4839 registered_command_names: Rc::clone(®istered_command_names),
4840 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
4841 registered_language_configs: Rc::clone(®istered_language_configs),
4842 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
4843 plugin_api_exports: Rc::clone(&plugin_api_exports),
4844 plugin_name: plugin_name.to_string(),
4845 };
4846 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4847
4848 globals.set("editor", editor)?;
4850
4851 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4853
4854 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4856
4857ctx.eval::<(), _>(
4864 r#"
4865 (function() {
4866 const originalOn = editor.on.bind(editor);
4867 const originalOff = editor.off.bind(editor);
4868 let counter = 0;
4869 const anonNames = new WeakMap();
4870 editor.on = function(eventName, handlerOrName) {
4871 if (typeof handlerOrName === 'function') {
4872 const existing = anonNames.get(handlerOrName);
4873 const name = existing || `__anon_on_${++counter}`;
4874 if (!existing) {
4875 anonNames.set(handlerOrName, name);
4876 }
4877 globalThis[name] = handlerOrName;
4878 return originalOn(eventName, name);
4879 }
4880 return originalOn(eventName, handlerOrName);
4881 };
4882 editor.off = function(eventName, handlerOrName) {
4883 if (typeof handlerOrName === 'function') {
4884 const name = anonNames.get(handlerOrName);
4885 if (name === undefined) return false;
4886 return originalOff(eventName, name);
4887 }
4888 return originalOff(eventName, handlerOrName);
4889 };
4890 })();
4891 "#,
4892 )?;
4893
4894 let console = Object::new(ctx.clone())?;
4897 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4898 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4899 tracing::info!("console.log: {}", parts.join(" "));
4900 })?)?;
4901 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4902 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4903 tracing::warn!("console.warn: {}", parts.join(" "));
4904 })?)?;
4905 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4906 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4907 tracing::error!("console.error: {}", parts.join(" "));
4908 })?)?;
4909 globals.set("console", console)?;
4910
4911 ctx.eval::<(), _>(r#"
4913 // Pending promise callbacks: callbackId -> { resolve, reject }
4914 globalThis._pendingCallbacks = new Map();
4915
4916 // Resolve a pending callback (called from Rust)
4917 globalThis._resolveCallback = function(callbackId, result) {
4918 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4919 const cb = globalThis._pendingCallbacks.get(callbackId);
4920 if (cb) {
4921 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4922 globalThis._pendingCallbacks.delete(callbackId);
4923 cb.resolve(result);
4924 console.log('[JS] _resolveCallback: resolve() called');
4925 } else {
4926 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4927 }
4928 };
4929
4930 // Reject a pending callback (called from Rust)
4931 globalThis._rejectCallback = function(callbackId, error) {
4932 const cb = globalThis._pendingCallbacks.get(callbackId);
4933 if (cb) {
4934 globalThis._pendingCallbacks.delete(callbackId);
4935 cb.reject(new Error(error));
4936 }
4937 };
4938
4939 // Streaming callbacks: called multiple times with partial results
4940 globalThis._streamingCallbacks = new Map();
4941
4942 // Called from Rust with partial data. When done=true, cleans up.
4943 globalThis._callStreamingCallback = function(callbackId, result, done) {
4944 const cb = globalThis._streamingCallbacks.get(callbackId);
4945 if (cb) {
4946 cb(result, done);
4947 if (done) {
4948 globalThis._streamingCallbacks.delete(callbackId);
4949 }
4950 }
4951 };
4952
4953 // Generic async wrapper decorator
4954 // Wraps a function that returns a callbackId into a promise-returning function
4955 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4956 // NOTE: We pass the method name as a string and call via bracket notation
4957 // to preserve rquickjs's automatic Ctx injection for methods
4958 globalThis._wrapAsync = function(methodName, fnName) {
4959 const startFn = editor[methodName];
4960 if (typeof startFn !== 'function') {
4961 // Return a function that always throws - catches missing implementations
4962 return function(...args) {
4963 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4964 editor.debug(`[ASYNC ERROR] ${error.message}`);
4965 throw error;
4966 };
4967 }
4968 return function(...args) {
4969 // Call via bracket notation to preserve method binding and Ctx injection
4970 const callbackId = editor[methodName](...args);
4971 return new Promise((resolve, reject) => {
4972 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4973 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4974 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4975 });
4976 };
4977 };
4978
4979 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4980 // The returned object has .result promise and is itself thenable
4981 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4982 const startFn = editor[methodName];
4983 if (typeof startFn !== 'function') {
4984 // Return a function that always throws - catches missing implementations
4985 return function(...args) {
4986 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4987 editor.debug(`[ASYNC ERROR] ${error.message}`);
4988 throw error;
4989 };
4990 }
4991 return function(...args) {
4992 // Call via bracket notation to preserve method binding and Ctx injection
4993 const callbackId = editor[methodName](...args);
4994 const resultPromise = new Promise((resolve, reject) => {
4995 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4996 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4997 });
4998 return {
4999 get result() { return resultPromise; },
5000 then(onFulfilled, onRejected) {
5001 return resultPromise.then(onFulfilled, onRejected);
5002 },
5003 catch(onRejected) {
5004 return resultPromise.catch(onRejected);
5005 }
5006 };
5007 };
5008 };
5009
5010 // Apply wrappers to async functions on editor
5011 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
5012 // spawnHostProcess gets a bespoke wrapper (instead of
5013 // `_wrapAsyncThenable`) because its `ProcessHandle`
5014 // exposes a real `kill()` that forwards to
5015 // `_killHostProcess`. Generic wrap has no hook for
5016 // that.
5017 editor.spawnHostProcess = function(command, args, cwd) {
5018 if (typeof editor._spawnHostProcessStart !== 'function') {
5019 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
5020 }
5021 // Pass real strings only. Earlier revisions forwarded
5022 // `""` for a missing cwd, which landed verbatim as
5023 // `Command::current_dir("")` in the dispatcher —
5024 // every host-spawn then failed with ENOENT. Use two
5025 // arity forms so the Rust `Opt<String>` stays `None`
5026 // instead of `Some("")`.
5027 let callbackId;
5028 if (typeof cwd === "string" && cwd.length > 0) {
5029 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
5030 } else {
5031 callbackId = editor._spawnHostProcessStart(command, args || []);
5032 }
5033 const resultPromise = new Promise(function(resolve, reject) {
5034 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
5035 });
5036 return {
5037 processId: callbackId,
5038 get result() { return resultPromise; },
5039 then: function(f, r) { return resultPromise.then(f, r); },
5040 catch: function(r) { return resultPromise.catch(r); },
5041 kill: function() {
5042 // Returns true when the kill was enqueued
5043 // (the process may have already exited; in
5044 // that case the dispatcher silently
5045 // drops it). Matches the
5046 // `ProcessHandle.kill(): Promise<boolean>`
5047 // type signature by wrapping the sync
5048 // boolean in a Promise.
5049 return Promise.resolve(editor._killHostProcess(callbackId));
5050 }
5051 };
5052 };
5053 editor.delay = _wrapAsync("_delayStart", "delay");
5054 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
5055 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
5056 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
5057 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
5058 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
5059 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
5060 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
5061 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
5062 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
5063 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
5064 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
5065 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
5066 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
5067 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
5068 editor.prompt = _wrapAsync("_promptStart", "prompt");
5069 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
5070 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
5071 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
5072 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
5073 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
5074 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
5075
5076 // Streaming grep: takes a progress callback, returns a thenable with searchId
5077 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
5078 opts = opts || {};
5079 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
5080 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
5081 const maxResults = opts.maxResults || 10000;
5082 const wholeWords = opts.wholeWords || false;
5083
5084 const searchId = editor._grepProjectStreamingStart(
5085 pattern, fixedString, caseSensitive, maxResults, wholeWords
5086 );
5087
5088 // Register streaming callback
5089 if (progressCallback) {
5090 globalThis._streamingCallbacks.set(searchId, progressCallback);
5091 }
5092
5093 // Create completion promise (resolved via _resolveCallback when search finishes)
5094 const resultPromise = new Promise(function(resolve, reject) {
5095 globalThis._pendingCallbacks.set(searchId, {
5096 resolve: function(result) {
5097 globalThis._streamingCallbacks.delete(searchId);
5098 resolve(result);
5099 },
5100 reject: function(err) {
5101 globalThis._streamingCallbacks.delete(searchId);
5102 reject(err);
5103 }
5104 });
5105 });
5106
5107 return {
5108 searchId: searchId,
5109 get result() { return resultPromise; },
5110 then: function(f, r) { return resultPromise.then(f, r); },
5111 catch: function(r) { return resultPromise.catch(r); }
5112 };
5113 };
5114
5115 // Wrapper for deleteTheme - wraps sync function in Promise
5116 editor.deleteTheme = function(name) {
5117 return new Promise(function(resolve, reject) {
5118 const success = editor._deleteThemeSync(name);
5119 if (success) {
5120 resolve();
5121 } else {
5122 reject(new Error("Failed to delete theme: " + name));
5123 }
5124 });
5125 };
5126 "#.as_bytes())?;
5127
5128 Ok::<_, rquickjs::Error>(())
5129 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
5130
5131 Ok(())
5132 }
5133
5134 pub async fn load_module_with_source(
5136 &mut self,
5137 path: &str,
5138 _plugin_source: &str,
5139 ) -> Result<()> {
5140 let path_buf = PathBuf::from(path);
5141 let source = std::fs::read_to_string(&path_buf)
5142 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
5143
5144 let filename = path_buf
5145 .file_name()
5146 .and_then(|s| s.to_str())
5147 .unwrap_or("plugin.ts");
5148
5149 if has_es_imports(&source) {
5151 match bundle_module(&path_buf) {
5153 Ok(bundled) => {
5154 self.execute_js(&bundled, path)?;
5155 }
5156 Err(e) => {
5157 tracing::warn!(
5158 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
5159 path,
5160 e
5161 );
5162 return Ok(()); }
5164 }
5165 } else if has_es_module_syntax(&source) {
5166 let stripped = strip_imports_and_exports(&source);
5168 let js_code = if filename.ends_with(".ts") {
5169 transpile_typescript(&stripped, filename)?
5170 } else {
5171 stripped
5172 };
5173 self.execute_js(&js_code, path)?;
5174 } else {
5175 let js_code = if filename.ends_with(".ts") {
5177 transpile_typescript(&source, filename)?
5178 } else {
5179 source
5180 };
5181 self.execute_js(&js_code, path)?;
5182 }
5183
5184 Ok(())
5185 }
5186
5187 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
5189 let plugin_name = Path::new(source_name)
5191 .file_stem()
5192 .and_then(|s| s.to_str())
5193 .unwrap_or("unknown");
5194
5195 tracing::debug!(
5196 "execute_js: starting for plugin '{}' from '{}'",
5197 plugin_name,
5198 source_name
5199 );
5200
5201 let context = {
5203 let mut contexts = self.plugin_contexts.borrow_mut();
5204 if let Some(ctx) = contexts.get(plugin_name) {
5205 ctx.clone()
5206 } else {
5207 let ctx = Context::full(&self.runtime).map_err(|e| {
5208 anyhow!(
5209 "Failed to create QuickJS context for plugin {}: {}",
5210 plugin_name,
5211 e
5212 )
5213 })?;
5214 self.setup_context_api(&ctx, plugin_name)?;
5215 contexts.insert(plugin_name.to_string(), ctx.clone());
5216 ctx
5217 }
5218 };
5219
5220 let wrapped_code = format!("(function() {{ {} }})();", code);
5224 let wrapped = wrapped_code.as_str();
5225
5226 context.with(|ctx| {
5227 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
5228
5229 let mut eval_options = rquickjs::context::EvalOptions::default();
5231 eval_options.global = true;
5232 eval_options.filename = Some(source_name.to_string());
5233 let result = ctx
5234 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
5235 .map_err(|e| format_js_error(&ctx, e, source_name));
5236
5237 tracing::debug!(
5238 "execute_js: plugin code execution finished for '{}', result: {:?}",
5239 plugin_name,
5240 result.is_ok()
5241 );
5242
5243 result
5244 })
5245 }
5246
5247 pub fn execute_source(
5253 &mut self,
5254 source: &str,
5255 plugin_name: &str,
5256 is_typescript: bool,
5257 ) -> Result<()> {
5258 use fresh_parser_js::{
5259 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
5260 };
5261
5262 if has_es_imports(source) {
5263 tracing::warn!(
5264 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
5265 plugin_name
5266 );
5267 }
5268
5269 let js_code = if has_es_module_syntax(source) {
5270 let stripped = strip_imports_and_exports(source);
5271 if is_typescript {
5272 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
5273 } else {
5274 stripped
5275 }
5276 } else if is_typescript {
5277 transpile_typescript(source, &format!("{}.ts", plugin_name))?
5278 } else {
5279 source.to_string()
5280 };
5281
5282 let source_name = format!(
5284 "{}.{}",
5285 plugin_name,
5286 if is_typescript { "ts" } else { "js" }
5287 );
5288 self.execute_js(&js_code, &source_name)
5289 }
5290
5291 pub fn cleanup_plugin(&self, plugin_name: &str) {
5297 self.plugin_contexts.borrow_mut().remove(plugin_name);
5299
5300 for handlers in self.event_handlers.borrow_mut().values_mut() {
5302 handlers.retain(|h| h.plugin_name != plugin_name);
5303 }
5304
5305 self.registered_actions
5307 .borrow_mut()
5308 .retain(|_, h| h.plugin_name != plugin_name);
5309
5310 self.callback_contexts
5312 .borrow_mut()
5313 .retain(|_, pname| pname != plugin_name);
5314
5315 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
5317 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
5319 std::collections::HashSet::new();
5320 for (buf_id, ns) in &tracked.overlay_namespaces {
5321 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
5322 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
5324 buffer_id: *buf_id,
5325 namespace: OverlayNamespace::from_string(ns.clone()),
5326 });
5327 let _ = self
5329 .command_sender
5330 .send(PluginCommand::ClearConcealNamespace {
5331 buffer_id: *buf_id,
5332 namespace: OverlayNamespace::from_string(ns.clone()),
5333 });
5334 let _ = self
5335 .command_sender
5336 .send(PluginCommand::ClearSoftBreakNamespace {
5337 buffer_id: *buf_id,
5338 namespace: OverlayNamespace::from_string(ns.clone()),
5339 });
5340 }
5341 }
5342
5343 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
5349 std::collections::HashSet::new();
5350 for (buf_id, ns) in &tracked.line_indicator_namespaces {
5351 if seen_li_ns.insert((buf_id.0, ns.clone())) {
5352 let _ = self
5353 .command_sender
5354 .send(PluginCommand::ClearLineIndicators {
5355 buffer_id: *buf_id,
5356 namespace: ns.clone(),
5357 });
5358 }
5359 }
5360
5361 let mut seen_vt: std::collections::HashSet<(usize, String)> =
5363 std::collections::HashSet::new();
5364 for (buf_id, vt_id) in &tracked.virtual_text_ids {
5365 if seen_vt.insert((buf_id.0, vt_id.clone())) {
5366 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
5367 buffer_id: *buf_id,
5368 virtual_text_id: vt_id.clone(),
5369 });
5370 }
5371 }
5372
5373 let mut seen_fe_ns: std::collections::HashSet<String> =
5375 std::collections::HashSet::new();
5376 for ns in &tracked.file_explorer_namespaces {
5377 if seen_fe_ns.insert(ns.clone()) {
5378 let _ = self
5379 .command_sender
5380 .send(PluginCommand::ClearFileExplorerDecorations {
5381 namespace: ns.clone(),
5382 });
5383 }
5384 }
5385
5386 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
5388 for ctx_name in &tracked.contexts_set {
5389 if seen_ctx.insert(ctx_name.clone()) {
5390 let _ = self.command_sender.send(PluginCommand::SetContext {
5391 name: ctx_name.clone(),
5392 active: false,
5393 });
5394 }
5395 }
5396
5397 for process_id in &tracked.background_process_ids {
5401 let _ = self
5402 .command_sender
5403 .send(PluginCommand::KillBackgroundProcess {
5404 process_id: *process_id,
5405 });
5406 }
5407
5408 for group_id in &tracked.scroll_sync_group_ids {
5410 let _ = self
5411 .command_sender
5412 .send(PluginCommand::RemoveScrollSyncGroup {
5413 group_id: *group_id,
5414 });
5415 }
5416
5417 for buffer_id in &tracked.virtual_buffer_ids {
5419 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
5420 buffer_id: *buffer_id,
5421 });
5422 }
5423
5424 for buffer_id in &tracked.composite_buffer_ids {
5426 let _ = self
5427 .command_sender
5428 .send(PluginCommand::CloseCompositeBuffer {
5429 buffer_id: *buffer_id,
5430 });
5431 }
5432
5433 for terminal_id in &tracked.terminal_ids {
5435 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
5436 terminal_id: *terminal_id,
5437 });
5438 }
5439 }
5440
5441 if let Ok(mut owners) = self.async_resource_owners.lock() {
5443 owners.retain(|_, name| name != plugin_name);
5444 }
5445
5446 self.plugin_api_exports
5448 .borrow_mut()
5449 .retain(|_, (exporter, _)| exporter != plugin_name);
5450
5451 self.registered_command_names
5453 .borrow_mut()
5454 .retain(|_, pname| pname != plugin_name);
5455 self.registered_grammar_languages
5456 .borrow_mut()
5457 .retain(|_, pname| pname != plugin_name);
5458 self.registered_language_configs
5459 .borrow_mut()
5460 .retain(|_, pname| pname != plugin_name);
5461 self.registered_lsp_servers
5462 .borrow_mut()
5463 .retain(|_, pname| pname != plugin_name);
5464
5465 tracing::debug!(
5466 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
5467 plugin_name
5468 );
5469 }
5470
5471 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
5473 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
5474
5475 self.services
5476 .set_js_execution_state(format!("hook '{}'", event_name));
5477
5478 let handlers = self.event_handlers.borrow().get(event_name).cloned();
5479 if let Some(handler_pairs) = handlers {
5480 let plugin_contexts = self.plugin_contexts.borrow();
5481 for handler in &handler_pairs {
5482 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
5483 continue;
5484 };
5485 context.with(|ctx| {
5486 call_handler(&ctx, &handler.handler_name, event_data);
5487 });
5488 }
5489 }
5490
5491 self.services.clear_js_execution_state();
5492 Ok(true)
5493 }
5494
5495 pub fn has_handlers(&self, event_name: &str) -> bool {
5497 self.event_handlers
5498 .borrow()
5499 .get(event_name)
5500 .map(|v| !v.is_empty())
5501 .unwrap_or(false)
5502 }
5503
5504 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
5508 let (lookup_name, text_input_char) =
5511 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
5512 ("mode_text_input", Some(ch.to_string()))
5513 } else {
5514 (action_name, None)
5515 };
5516
5517 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
5518 let (plugin_name, function_name) = match pair {
5519 Some(handler) => (handler.plugin_name, handler.handler_name),
5520 None => ("main".to_string(), lookup_name.to_string()),
5521 };
5522
5523 let plugin_contexts = self.plugin_contexts.borrow();
5524 let context = plugin_contexts
5525 .get(&plugin_name)
5526 .unwrap_or(&self.main_context);
5527
5528 self.services
5530 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
5531
5532 tracing::info!(
5533 "start_action: BEGIN '{}' -> function '{}'",
5534 action_name,
5535 function_name
5536 );
5537
5538 let call_args = if let Some(ref ch) = text_input_char {
5541 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
5542 format!("({{text:\"{}\"}})", escaped)
5543 } else {
5544 "()".to_string()
5545 };
5546
5547 let code = format!(
5548 r#"
5549 (function() {{
5550 console.log('[JS] start_action: calling {fn}');
5551 try {{
5552 if (typeof globalThis.{fn} === 'function') {{
5553 console.log('[JS] start_action: {fn} is a function, invoking...');
5554 globalThis.{fn}{args};
5555 console.log('[JS] start_action: {fn} invoked (may be async)');
5556 }} else {{
5557 console.error('[JS] Action {action} is not defined as a global function');
5558 }}
5559 }} catch (e) {{
5560 console.error('[JS] Action {action} error:', e);
5561 }}
5562 }})();
5563 "#,
5564 fn = function_name,
5565 action = action_name,
5566 args = call_args
5567 );
5568
5569 tracing::info!("start_action: evaluating JS code");
5570 context.with(|ctx| {
5571 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5572 log_js_error(&ctx, e, &format!("action {}", action_name));
5573 }
5574 tracing::info!("start_action: running pending microtasks");
5575 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
5577 tracing::info!("start_action: executed {} pending jobs", count);
5578 });
5579
5580 tracing::info!("start_action: END '{}'", action_name);
5581
5582 self.services.clear_js_execution_state();
5584
5585 Ok(())
5586 }
5587
5588 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
5590 let pair = self.registered_actions.borrow().get(action_name).cloned();
5592 let (plugin_name, function_name) = match pair {
5593 Some(handler) => (handler.plugin_name, handler.handler_name),
5594 None => ("main".to_string(), action_name.to_string()),
5595 };
5596
5597 let plugin_contexts = self.plugin_contexts.borrow();
5598 let context = plugin_contexts
5599 .get(&plugin_name)
5600 .unwrap_or(&self.main_context);
5601
5602 tracing::debug!(
5603 "execute_action: '{}' -> function '{}'",
5604 action_name,
5605 function_name
5606 );
5607
5608 let code = format!(
5611 r#"
5612 (async function() {{
5613 try {{
5614 if (typeof globalThis.{fn} === 'function') {{
5615 const result = globalThis.{fn}();
5616 // If it's a Promise, await it
5617 if (result && typeof result.then === 'function') {{
5618 await result;
5619 }}
5620 }} else {{
5621 console.error('Action {action} is not defined as a global function');
5622 }}
5623 }} catch (e) {{
5624 console.error('Action {action} error:', e);
5625 }}
5626 }})();
5627 "#,
5628 fn = function_name,
5629 action = action_name
5630 );
5631
5632 context.with(|ctx| {
5633 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5635 Ok(value) => {
5636 if value.is_object() {
5638 if let Some(obj) = value.as_object() {
5639 if obj.get::<_, rquickjs::Function>("then").is_ok() {
5641 run_pending_jobs_checked(
5644 &ctx,
5645 &format!("execute_action {} promise", action_name),
5646 );
5647 }
5648 }
5649 }
5650 }
5651 Err(e) => {
5652 log_js_error(&ctx, e, &format!("action {}", action_name));
5653 }
5654 }
5655 });
5656
5657 Ok(())
5658 }
5659
5660 pub fn poll_event_loop_once(&mut self) -> bool {
5662 let mut had_work = false;
5663
5664 self.main_context.with(|ctx| {
5666 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
5667 if count > 0 {
5668 had_work = true;
5669 }
5670 });
5671
5672 let contexts = self.plugin_contexts.borrow().clone();
5674 for (name, context) in contexts {
5675 context.with(|ctx| {
5676 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
5677 if count > 0 {
5678 had_work = true;
5679 }
5680 });
5681 }
5682 had_work
5683 }
5684
5685 pub fn send_status(&self, message: String) {
5687 let _ = self
5688 .command_sender
5689 .send(PluginCommand::SetStatus { message });
5690 }
5691
5692 pub fn send_hook_completed(&self, hook_name: String) {
5696 let _ = self
5697 .command_sender
5698 .send(PluginCommand::HookCompleted { hook_name });
5699 }
5700
5701 pub fn resolve_callback(
5706 &mut self,
5707 callback_id: fresh_core::api::JsCallbackId,
5708 result_json: &str,
5709 ) {
5710 let id = callback_id.as_u64();
5711 tracing::debug!("resolve_callback: starting for callback_id={}", id);
5712
5713 let plugin_name = {
5715 let mut contexts = self.callback_contexts.borrow_mut();
5716 contexts.remove(&id)
5717 };
5718
5719 let Some(name) = plugin_name else {
5720 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5721 return;
5722 };
5723
5724 let plugin_contexts = self.plugin_contexts.borrow();
5725 let Some(context) = plugin_contexts.get(&name) else {
5726 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5727 return;
5728 };
5729
5730 context.with(|ctx| {
5731 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5733 Ok(v) => v,
5734 Err(e) => {
5735 tracing::error!(
5736 "resolve_callback: failed to parse JSON for callback_id={}: {}",
5737 id,
5738 e
5739 );
5740 return;
5741 }
5742 };
5743
5744 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5746 Ok(v) => v,
5747 Err(e) => {
5748 tracing::error!(
5749 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5750 id,
5751 e
5752 );
5753 return;
5754 }
5755 };
5756
5757 let globals = ctx.globals();
5759 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5760 Ok(f) => f,
5761 Err(e) => {
5762 tracing::error!(
5763 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5764 id,
5765 e
5766 );
5767 return;
5768 }
5769 };
5770
5771 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5773 log_js_error(&ctx, e, &format!("resolving callback {}", id));
5774 }
5775
5776 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5778 tracing::info!(
5779 "resolve_callback: executed {} pending jobs for callback_id={}",
5780 job_count,
5781 id
5782 );
5783 });
5784 }
5785
5786 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5788 let id = callback_id.as_u64();
5789
5790 let plugin_name = {
5792 let mut contexts = self.callback_contexts.borrow_mut();
5793 contexts.remove(&id)
5794 };
5795
5796 let Some(name) = plugin_name else {
5797 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5798 return;
5799 };
5800
5801 let plugin_contexts = self.plugin_contexts.borrow();
5802 let Some(context) = plugin_contexts.get(&name) else {
5803 tracing::warn!("reject_callback: Context lost for plugin {}", name);
5804 return;
5805 };
5806
5807 context.with(|ctx| {
5808 let globals = ctx.globals();
5810 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5811 Ok(f) => f,
5812 Err(e) => {
5813 tracing::error!(
5814 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5815 id,
5816 e
5817 );
5818 return;
5819 }
5820 };
5821
5822 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5824 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5825 }
5826
5827 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5829 });
5830 }
5831
5832 pub fn call_streaming_callback(
5836 &mut self,
5837 callback_id: fresh_core::api::JsCallbackId,
5838 result_json: &str,
5839 done: bool,
5840 ) {
5841 let id = callback_id.as_u64();
5842
5843 let plugin_name = {
5845 let contexts = self.callback_contexts.borrow();
5846 contexts.get(&id).cloned()
5847 };
5848
5849 let Some(name) = plugin_name else {
5850 tracing::warn!(
5851 "call_streaming_callback: No plugin found for callback_id={}",
5852 id
5853 );
5854 return;
5855 };
5856
5857 if done {
5859 self.callback_contexts.borrow_mut().remove(&id);
5860 }
5861
5862 let plugin_contexts = self.plugin_contexts.borrow();
5863 let Some(context) = plugin_contexts.get(&name) else {
5864 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5865 return;
5866 };
5867
5868 context.with(|ctx| {
5869 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5870 Ok(v) => v,
5871 Err(e) => {
5872 tracing::error!(
5873 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5874 id,
5875 e
5876 );
5877 return;
5878 }
5879 };
5880
5881 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5882 Ok(v) => v,
5883 Err(e) => {
5884 tracing::error!(
5885 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5886 id,
5887 e
5888 );
5889 return;
5890 }
5891 };
5892
5893 let globals = ctx.globals();
5894 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5895 Ok(f) => f,
5896 Err(e) => {
5897 tracing::error!(
5898 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5899 id,
5900 e
5901 );
5902 return;
5903 }
5904 };
5905
5906 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5907 log_js_error(
5908 &ctx,
5909 e,
5910 &format!("calling streaming callback {}", id),
5911 );
5912 }
5913
5914 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5915 });
5916 }
5917}
5918
5919#[cfg(test)]
5920mod tests {
5921 use super::*;
5922 use fresh_core::api::{BufferInfo, CursorInfo};
5923 use std::sync::mpsc;
5924
5925 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5927 let (tx, rx) = mpsc::channel();
5928 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5929 let services = Arc::new(TestServiceBridge::new());
5930 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5931 (backend, rx)
5932 }
5933
5934 struct TestServiceBridge {
5935 en_strings: std::sync::Mutex<HashMap<String, String>>,
5936 }
5937
5938 impl TestServiceBridge {
5939 fn new() -> Self {
5940 Self {
5941 en_strings: std::sync::Mutex::new(HashMap::new()),
5942 }
5943 }
5944 }
5945
5946 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5947 fn as_any(&self) -> &dyn std::any::Any {
5948 self
5949 }
5950 fn translate(
5951 &self,
5952 _plugin_name: &str,
5953 key: &str,
5954 _args: &HashMap<String, String>,
5955 ) -> String {
5956 self.en_strings
5957 .lock()
5958 .unwrap()
5959 .get(key)
5960 .cloned()
5961 .unwrap_or_else(|| key.to_string())
5962 }
5963 fn current_locale(&self) -> String {
5964 "en".to_string()
5965 }
5966 fn set_js_execution_state(&self, _state: String) {}
5967 fn clear_js_execution_state(&self) {}
5968 fn get_theme_schema(&self) -> serde_json::Value {
5969 serde_json::json!({})
5970 }
5971 fn get_builtin_themes(&self) -> serde_json::Value {
5972 serde_json::json!([])
5973 }
5974 fn register_command(&self, _command: fresh_core::command::Command) {}
5975 fn unregister_command(&self, _name: &str) {}
5976 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5977 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5978 fn plugins_dir(&self) -> std::path::PathBuf {
5979 std::path::PathBuf::from("/tmp/plugins")
5980 }
5981 fn config_dir(&self) -> std::path::PathBuf {
5982 std::path::PathBuf::from("/tmp/config")
5983 }
5984 fn data_dir(&self) -> std::path::PathBuf {
5985 std::path::PathBuf::from("/tmp/data")
5986 }
5987 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
5988 None
5989 }
5990 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
5991 Err("not implemented in test".to_string())
5992 }
5993 fn theme_file_exists(&self, _name: &str) -> bool {
5994 false
5995 }
5996 }
5997
5998 #[test]
5999 fn test_quickjs_backend_creation() {
6000 let backend = QuickJsBackend::new();
6001 assert!(backend.is_ok());
6002 }
6003
6004 #[test]
6005 fn test_execute_simple_js() {
6006 let mut backend = QuickJsBackend::new().unwrap();
6007 let result = backend.execute_js("const x = 1 + 2;", "test.js");
6008 assert!(result.is_ok());
6009 }
6010
6011 #[test]
6012 fn test_event_handler_registration() {
6013 let backend = QuickJsBackend::new().unwrap();
6014
6015 assert!(!backend.has_handlers("test_event"));
6017
6018 backend
6020 .event_handlers
6021 .borrow_mut()
6022 .entry("test_event".to_string())
6023 .or_default()
6024 .push(PluginHandler {
6025 plugin_name: "test".to_string(),
6026 handler_name: "testHandler".to_string(),
6027 });
6028
6029 assert!(backend.has_handlers("test_event"));
6031 }
6032
6033 #[test]
6036 fn test_api_set_status() {
6037 let (mut backend, rx) = create_test_backend();
6038
6039 backend
6040 .execute_js(
6041 r#"
6042 const editor = getEditor();
6043 editor.setStatus("Hello from test");
6044 "#,
6045 "test.js",
6046 )
6047 .unwrap();
6048
6049 let cmd = rx.try_recv().unwrap();
6050 match cmd {
6051 PluginCommand::SetStatus { message } => {
6052 assert_eq!(message, "Hello from test");
6053 }
6054 _ => panic!("Expected SetStatus command, got {:?}", cmd),
6055 }
6056 }
6057
6058 #[test]
6059 fn test_api_register_command() {
6060 let (mut backend, rx) = create_test_backend();
6061
6062 backend
6063 .execute_js(
6064 r#"
6065 const editor = getEditor();
6066 globalThis.myTestHandler = function() { };
6067 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
6068 "#,
6069 "test_plugin.js",
6070 )
6071 .unwrap();
6072
6073 let cmd = rx.try_recv().unwrap();
6074 match cmd {
6075 PluginCommand::RegisterCommand { command } => {
6076 assert_eq!(command.name, "Test Command");
6077 assert_eq!(command.description, "A test command");
6078 assert_eq!(command.plugin_name, "test_plugin");
6080 }
6081 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
6082 }
6083 }
6084
6085 #[test]
6086 fn test_api_define_mode() {
6087 let (mut backend, rx) = create_test_backend();
6088
6089 backend
6090 .execute_js(
6091 r#"
6092 const editor = getEditor();
6093 editor.defineMode("test-mode", [
6094 ["a", "action_a"],
6095 ["b", "action_b"]
6096 ]);
6097 "#,
6098 "test.js",
6099 )
6100 .unwrap();
6101
6102 let cmd = rx.try_recv().unwrap();
6103 match cmd {
6104 PluginCommand::DefineMode {
6105 name,
6106 bindings,
6107 read_only,
6108 allow_text_input,
6109 inherit_normal_bindings,
6110 plugin_name,
6111 } => {
6112 assert_eq!(name, "test-mode");
6113 assert_eq!(bindings.len(), 2);
6114 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
6115 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
6116 assert!(!read_only);
6117 assert!(!allow_text_input);
6118 assert!(!inherit_normal_bindings);
6119 assert!(plugin_name.is_some());
6120 }
6121 _ => panic!("Expected DefineMode, got {:?}", cmd),
6122 }
6123 }
6124
6125 #[test]
6126 fn test_api_set_editor_mode() {
6127 let (mut backend, rx) = create_test_backend();
6128
6129 backend
6130 .execute_js(
6131 r#"
6132 const editor = getEditor();
6133 editor.setEditorMode("vi-normal");
6134 "#,
6135 "test.js",
6136 )
6137 .unwrap();
6138
6139 let cmd = rx.try_recv().unwrap();
6140 match cmd {
6141 PluginCommand::SetEditorMode { mode } => {
6142 assert_eq!(mode, Some("vi-normal".to_string()));
6143 }
6144 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
6145 }
6146 }
6147
6148 #[test]
6149 fn test_api_clear_editor_mode() {
6150 let (mut backend, rx) = create_test_backend();
6151
6152 backend
6153 .execute_js(
6154 r#"
6155 const editor = getEditor();
6156 editor.setEditorMode(null);
6157 "#,
6158 "test.js",
6159 )
6160 .unwrap();
6161
6162 let cmd = rx.try_recv().unwrap();
6163 match cmd {
6164 PluginCommand::SetEditorMode { mode } => {
6165 assert!(mode.is_none());
6166 }
6167 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
6168 }
6169 }
6170
6171 #[test]
6172 fn test_api_insert_at_cursor() {
6173 let (mut backend, rx) = create_test_backend();
6174
6175 backend
6176 .execute_js(
6177 r#"
6178 const editor = getEditor();
6179 editor.insertAtCursor("Hello, World!");
6180 "#,
6181 "test.js",
6182 )
6183 .unwrap();
6184
6185 let cmd = rx.try_recv().unwrap();
6186 match cmd {
6187 PluginCommand::InsertAtCursor { text } => {
6188 assert_eq!(text, "Hello, World!");
6189 }
6190 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
6191 }
6192 }
6193
6194 #[test]
6195 fn test_api_set_context() {
6196 let (mut backend, rx) = create_test_backend();
6197
6198 backend
6199 .execute_js(
6200 r#"
6201 const editor = getEditor();
6202 editor.setContext("myContext", true);
6203 "#,
6204 "test.js",
6205 )
6206 .unwrap();
6207
6208 let cmd = rx.try_recv().unwrap();
6209 match cmd {
6210 PluginCommand::SetContext { name, active } => {
6211 assert_eq!(name, "myContext");
6212 assert!(active);
6213 }
6214 _ => panic!("Expected SetContext, got {:?}", cmd),
6215 }
6216 }
6217
6218 #[tokio::test]
6219 async fn test_execute_action_sync_function() {
6220 let (mut backend, rx) = create_test_backend();
6221
6222 backend.registered_actions.borrow_mut().insert(
6224 "my_sync_action".to_string(),
6225 PluginHandler {
6226 plugin_name: "test".to_string(),
6227 handler_name: "my_sync_action".to_string(),
6228 },
6229 );
6230
6231 backend
6233 .execute_js(
6234 r#"
6235 const editor = getEditor();
6236 globalThis.my_sync_action = function() {
6237 editor.setStatus("sync action executed");
6238 };
6239 "#,
6240 "test.js",
6241 )
6242 .unwrap();
6243
6244 while rx.try_recv().is_ok() {}
6246
6247 backend.execute_action("my_sync_action").await.unwrap();
6249
6250 let cmd = rx.try_recv().unwrap();
6252 match cmd {
6253 PluginCommand::SetStatus { message } => {
6254 assert_eq!(message, "sync action executed");
6255 }
6256 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
6257 }
6258 }
6259
6260 #[tokio::test]
6261 async fn test_execute_action_async_function() {
6262 let (mut backend, rx) = create_test_backend();
6263
6264 backend.registered_actions.borrow_mut().insert(
6266 "my_async_action".to_string(),
6267 PluginHandler {
6268 plugin_name: "test".to_string(),
6269 handler_name: "my_async_action".to_string(),
6270 },
6271 );
6272
6273 backend
6275 .execute_js(
6276 r#"
6277 const editor = getEditor();
6278 globalThis.my_async_action = async function() {
6279 await Promise.resolve();
6280 editor.setStatus("async action executed");
6281 };
6282 "#,
6283 "test.js",
6284 )
6285 .unwrap();
6286
6287 while rx.try_recv().is_ok() {}
6289
6290 backend.execute_action("my_async_action").await.unwrap();
6292
6293 let cmd = rx.try_recv().unwrap();
6295 match cmd {
6296 PluginCommand::SetStatus { message } => {
6297 assert_eq!(message, "async action executed");
6298 }
6299 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
6300 }
6301 }
6302
6303 #[tokio::test]
6304 async fn test_execute_action_with_registered_handler() {
6305 let (mut backend, rx) = create_test_backend();
6306
6307 backend.registered_actions.borrow_mut().insert(
6309 "my_action".to_string(),
6310 PluginHandler {
6311 plugin_name: "test".to_string(),
6312 handler_name: "actual_handler_function".to_string(),
6313 },
6314 );
6315
6316 backend
6317 .execute_js(
6318 r#"
6319 const editor = getEditor();
6320 globalThis.actual_handler_function = function() {
6321 editor.setStatus("handler executed");
6322 };
6323 "#,
6324 "test.js",
6325 )
6326 .unwrap();
6327
6328 while rx.try_recv().is_ok() {}
6330
6331 backend.execute_action("my_action").await.unwrap();
6333
6334 let cmd = rx.try_recv().unwrap();
6335 match cmd {
6336 PluginCommand::SetStatus { message } => {
6337 assert_eq!(message, "handler executed");
6338 }
6339 _ => panic!("Expected SetStatus, got {:?}", cmd),
6340 }
6341 }
6342
6343 #[test]
6344 fn test_api_on_event_registration() {
6345 let (mut backend, _rx) = create_test_backend();
6346
6347 backend
6348 .execute_js(
6349 r#"
6350 const editor = getEditor();
6351 globalThis.myEventHandler = function() { };
6352 editor.on("bufferSave", "myEventHandler");
6353 "#,
6354 "test.js",
6355 )
6356 .unwrap();
6357
6358 assert!(backend.has_handlers("bufferSave"));
6359 }
6360
6361 #[test]
6362 fn test_api_off_event_unregistration() {
6363 let (mut backend, _rx) = create_test_backend();
6364
6365 backend
6366 .execute_js(
6367 r#"
6368 const editor = getEditor();
6369 globalThis.myEventHandler = function() { };
6370 editor.on("bufferSave", "myEventHandler");
6371 editor.off("bufferSave", "myEventHandler");
6372 "#,
6373 "test.js",
6374 )
6375 .unwrap();
6376
6377 assert!(!backend.has_handlers("bufferSave"));
6379 }
6380
6381 #[tokio::test]
6382 async fn test_emit_event() {
6383 let (mut backend, rx) = create_test_backend();
6384
6385 backend
6386 .execute_js(
6387 r#"
6388 const editor = getEditor();
6389 globalThis.onSaveHandler = function(data) {
6390 editor.setStatus("saved: " + JSON.stringify(data));
6391 };
6392 editor.on("bufferSave", "onSaveHandler");
6393 "#,
6394 "test.js",
6395 )
6396 .unwrap();
6397
6398 while rx.try_recv().is_ok() {}
6400
6401 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
6403 backend.emit("bufferSave", &event_data).await.unwrap();
6404
6405 let cmd = rx.try_recv().unwrap();
6406 match cmd {
6407 PluginCommand::SetStatus { message } => {
6408 assert!(message.contains("/test.txt"));
6409 }
6410 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
6411 }
6412 }
6413
6414 #[test]
6415 fn test_api_copy_to_clipboard() {
6416 let (mut backend, rx) = create_test_backend();
6417
6418 backend
6419 .execute_js(
6420 r#"
6421 const editor = getEditor();
6422 editor.copyToClipboard("clipboard text");
6423 "#,
6424 "test.js",
6425 )
6426 .unwrap();
6427
6428 let cmd = rx.try_recv().unwrap();
6429 match cmd {
6430 PluginCommand::SetClipboard { text } => {
6431 assert_eq!(text, "clipboard text");
6432 }
6433 _ => panic!("Expected SetClipboard, got {:?}", cmd),
6434 }
6435 }
6436
6437 #[test]
6438 fn test_api_open_file() {
6439 let (mut backend, rx) = create_test_backend();
6440
6441 backend
6443 .execute_js(
6444 r#"
6445 const editor = getEditor();
6446 editor.openFile("/path/to/file.txt", null, null);
6447 "#,
6448 "test.js",
6449 )
6450 .unwrap();
6451
6452 let cmd = rx.try_recv().unwrap();
6453 match cmd {
6454 PluginCommand::OpenFileAtLocation { path, line, column } => {
6455 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
6456 assert!(line.is_none());
6457 assert!(column.is_none());
6458 }
6459 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
6460 }
6461 }
6462
6463 #[test]
6464 fn test_api_delete_range() {
6465 let (mut backend, rx) = create_test_backend();
6466
6467 backend
6469 .execute_js(
6470 r#"
6471 const editor = getEditor();
6472 editor.deleteRange(0, 10, 20);
6473 "#,
6474 "test.js",
6475 )
6476 .unwrap();
6477
6478 let cmd = rx.try_recv().unwrap();
6479 match cmd {
6480 PluginCommand::DeleteRange { range, .. } => {
6481 assert_eq!(range.start, 10);
6482 assert_eq!(range.end, 20);
6483 }
6484 _ => panic!("Expected DeleteRange, got {:?}", cmd),
6485 }
6486 }
6487
6488 #[test]
6489 fn test_api_insert_text() {
6490 let (mut backend, rx) = create_test_backend();
6491
6492 backend
6494 .execute_js(
6495 r#"
6496 const editor = getEditor();
6497 editor.insertText(0, 5, "inserted");
6498 "#,
6499 "test.js",
6500 )
6501 .unwrap();
6502
6503 let cmd = rx.try_recv().unwrap();
6504 match cmd {
6505 PluginCommand::InsertText { position, text, .. } => {
6506 assert_eq!(position, 5);
6507 assert_eq!(text, "inserted");
6508 }
6509 _ => panic!("Expected InsertText, got {:?}", cmd),
6510 }
6511 }
6512
6513 #[test]
6514 fn test_api_set_buffer_cursor() {
6515 let (mut backend, rx) = create_test_backend();
6516
6517 backend
6519 .execute_js(
6520 r#"
6521 const editor = getEditor();
6522 editor.setBufferCursor(0, 100);
6523 "#,
6524 "test.js",
6525 )
6526 .unwrap();
6527
6528 let cmd = rx.try_recv().unwrap();
6529 match cmd {
6530 PluginCommand::SetBufferCursor { position, .. } => {
6531 assert_eq!(position, 100);
6532 }
6533 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
6534 }
6535 }
6536
6537 #[test]
6538 fn test_api_get_cursor_position_from_state() {
6539 let (tx, _rx) = mpsc::channel();
6540 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6541
6542 {
6544 let mut state = state_snapshot.write().unwrap();
6545 state.primary_cursor = Some(CursorInfo {
6546 position: 42,
6547 selection: None,
6548 });
6549 }
6550
6551 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6552 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6553
6554 backend
6556 .execute_js(
6557 r#"
6558 const editor = getEditor();
6559 const pos = editor.getCursorPosition();
6560 globalThis._testResult = pos;
6561 "#,
6562 "test.js",
6563 )
6564 .unwrap();
6565
6566 backend
6568 .plugin_contexts
6569 .borrow()
6570 .get("test")
6571 .unwrap()
6572 .clone()
6573 .with(|ctx| {
6574 let global = ctx.globals();
6575 let result: u32 = global.get("_testResult").unwrap();
6576 assert_eq!(result, 42);
6577 });
6578 }
6579
6580 #[test]
6581 fn test_api_path_functions() {
6582 let (mut backend, _rx) = create_test_backend();
6583
6584 #[cfg(windows)]
6587 let absolute_path = r#"C:\\foo\\bar"#;
6588 #[cfg(not(windows))]
6589 let absolute_path = "/foo/bar";
6590
6591 let js_code = format!(
6593 r#"
6594 const editor = getEditor();
6595 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
6596 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
6597 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
6598 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
6599 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
6600 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
6601 "#,
6602 absolute_path
6603 );
6604 backend.execute_js(&js_code, "test.js").unwrap();
6605
6606 backend
6607 .plugin_contexts
6608 .borrow()
6609 .get("test")
6610 .unwrap()
6611 .clone()
6612 .with(|ctx| {
6613 let global = ctx.globals();
6614 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
6615 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
6616 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
6617 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
6618 assert!(!global.get::<_, bool>("_isRelative").unwrap());
6619 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
6620 });
6621 }
6622
6623 #[test]
6631 fn test_path_join_preserves_unc_prefix() {
6632 let (mut backend, _rx) = create_test_backend();
6633 backend
6634 .execute_js(
6635 r#"
6636 const editor = getEditor();
6637 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
6638 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
6639 globalThis._posix = editor.pathJoin("/foo", "bar");
6640 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
6641 "#,
6642 "test.js",
6643 )
6644 .unwrap();
6645
6646 backend
6647 .plugin_contexts
6648 .borrow()
6649 .get("test")
6650 .unwrap()
6651 .clone()
6652 .with(|ctx| {
6653 let global = ctx.globals();
6654 assert_eq!(
6655 global.get::<_, String>("_unc").unwrap(),
6656 "//?/C:/workspace/.devcontainer/devcontainer.json",
6657 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
6658 );
6659 assert_eq!(
6660 global.get::<_, String>("_unc_fwd").unwrap(),
6661 "//?/C:/workspace/.devcontainer/devcontainer.json",
6662 "UNC prefix in forward-slash form stays as `//`",
6663 );
6664 assert_eq!(
6665 global.get::<_, String>("_posix").unwrap(),
6666 "/foo/bar",
6667 "POSIX absolute paths keep their single leading slash",
6668 );
6669 assert_eq!(
6670 global.get::<_, String>("_drive").unwrap(),
6671 "C:/foo/bar",
6672 "Windows drive-letter paths have no leading slash",
6673 );
6674 });
6675 }
6676
6677 #[test]
6678 fn test_file_uri_to_path_and_back() {
6679 let (mut backend, _rx) = create_test_backend();
6680
6681 #[cfg(not(windows))]
6683 let js_code = r#"
6684 const editor = getEditor();
6685 // Basic file URI to path
6686 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
6687 // Percent-encoded characters
6688 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
6689 // Invalid URI returns empty string
6690 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6691 // Path to file URI
6692 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
6693 // Round-trip
6694 globalThis._roundtrip = editor.fileUriToPath(
6695 editor.pathToFileUri("/home/user/file.txt")
6696 );
6697 "#;
6698
6699 #[cfg(windows)]
6700 let js_code = r#"
6701 const editor = getEditor();
6702 // Windows URI with encoded colon (the bug from issue #1071)
6703 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
6704 // Windows URI with normal colon
6705 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
6706 // Invalid URI returns empty string
6707 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6708 // Path to file URI
6709 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
6710 // Round-trip
6711 globalThis._roundtrip = editor.fileUriToPath(
6712 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
6713 );
6714 "#;
6715
6716 backend.execute_js(js_code, "test.js").unwrap();
6717
6718 backend
6719 .plugin_contexts
6720 .borrow()
6721 .get("test")
6722 .unwrap()
6723 .clone()
6724 .with(|ctx| {
6725 let global = ctx.globals();
6726
6727 #[cfg(not(windows))]
6728 {
6729 assert_eq!(
6730 global.get::<_, String>("_path1").unwrap(),
6731 "/home/user/file.txt"
6732 );
6733 assert_eq!(
6734 global.get::<_, String>("_path2").unwrap(),
6735 "/home/user/my file.txt"
6736 );
6737 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6738 assert_eq!(
6739 global.get::<_, String>("_uri1").unwrap(),
6740 "file:///home/user/file.txt"
6741 );
6742 assert_eq!(
6743 global.get::<_, String>("_roundtrip").unwrap(),
6744 "/home/user/file.txt"
6745 );
6746 }
6747
6748 #[cfg(windows)]
6749 {
6750 assert_eq!(
6752 global.get::<_, String>("_path1").unwrap(),
6753 "C:\\Users\\admin\\Repos\\file.cs"
6754 );
6755 assert_eq!(
6756 global.get::<_, String>("_path2").unwrap(),
6757 "C:\\Users\\admin\\Repos\\file.cs"
6758 );
6759 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6760 assert_eq!(
6761 global.get::<_, String>("_uri1").unwrap(),
6762 "file:///C:/Users/admin/Repos/file.cs"
6763 );
6764 assert_eq!(
6765 global.get::<_, String>("_roundtrip").unwrap(),
6766 "C:\\Users\\admin\\Repos\\file.cs"
6767 );
6768 }
6769 });
6770 }
6771
6772 #[test]
6773 fn test_typescript_transpilation() {
6774 use fresh_parser_js::transpile_typescript;
6775
6776 let (mut backend, rx) = create_test_backend();
6777
6778 let ts_code = r#"
6780 const editor = getEditor();
6781 function greet(name: string): string {
6782 return "Hello, " + name;
6783 }
6784 editor.setStatus(greet("TypeScript"));
6785 "#;
6786
6787 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6789
6790 backend.execute_js(&js_code, "test.js").unwrap();
6792
6793 let cmd = rx.try_recv().unwrap();
6794 match cmd {
6795 PluginCommand::SetStatus { message } => {
6796 assert_eq!(message, "Hello, TypeScript");
6797 }
6798 _ => panic!("Expected SetStatus, got {:?}", cmd),
6799 }
6800 }
6801
6802 #[test]
6803 fn test_api_get_buffer_text_sends_command() {
6804 let (mut backend, rx) = create_test_backend();
6805
6806 backend
6808 .execute_js(
6809 r#"
6810 const editor = getEditor();
6811 // Store the promise for later
6812 globalThis._textPromise = editor.getBufferText(0, 10, 20);
6813 "#,
6814 "test.js",
6815 )
6816 .unwrap();
6817
6818 let cmd = rx.try_recv().unwrap();
6820 match cmd {
6821 PluginCommand::GetBufferText {
6822 buffer_id,
6823 start,
6824 end,
6825 request_id,
6826 } => {
6827 assert_eq!(buffer_id.0, 0);
6828 assert_eq!(start, 10);
6829 assert_eq!(end, 20);
6830 assert!(request_id > 0); }
6832 _ => panic!("Expected GetBufferText, got {:?}", cmd),
6833 }
6834 }
6835
6836 #[test]
6837 fn test_api_get_buffer_text_resolves_callback() {
6838 let (mut backend, rx) = create_test_backend();
6839
6840 backend
6842 .execute_js(
6843 r#"
6844 const editor = getEditor();
6845 globalThis._resolvedText = null;
6846 editor.getBufferText(0, 0, 100).then(text => {
6847 globalThis._resolvedText = text;
6848 });
6849 "#,
6850 "test.js",
6851 )
6852 .unwrap();
6853
6854 let request_id = match rx.try_recv().unwrap() {
6856 PluginCommand::GetBufferText { request_id, .. } => request_id,
6857 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6858 };
6859
6860 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6862
6863 backend
6865 .plugin_contexts
6866 .borrow()
6867 .get("test")
6868 .unwrap()
6869 .clone()
6870 .with(|ctx| {
6871 run_pending_jobs_checked(&ctx, "test async getText");
6872 });
6873
6874 backend
6876 .plugin_contexts
6877 .borrow()
6878 .get("test")
6879 .unwrap()
6880 .clone()
6881 .with(|ctx| {
6882 let global = ctx.globals();
6883 let result: String = global.get("_resolvedText").unwrap();
6884 assert_eq!(result, "hello world");
6885 });
6886 }
6887
6888 #[test]
6889 fn test_plugin_translation() {
6890 let (mut backend, _rx) = create_test_backend();
6891
6892 backend
6894 .execute_js(
6895 r#"
6896 const editor = getEditor();
6897 globalThis._translated = editor.t("test.key");
6898 "#,
6899 "test.js",
6900 )
6901 .unwrap();
6902
6903 backend
6904 .plugin_contexts
6905 .borrow()
6906 .get("test")
6907 .unwrap()
6908 .clone()
6909 .with(|ctx| {
6910 let global = ctx.globals();
6911 let result: String = global.get("_translated").unwrap();
6913 assert_eq!(result, "test.key");
6914 });
6915 }
6916
6917 #[test]
6918 fn test_plugin_translation_with_registered_strings() {
6919 let (mut backend, _rx) = create_test_backend();
6920
6921 let mut en_strings = std::collections::HashMap::new();
6923 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6924 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6925
6926 let mut strings = std::collections::HashMap::new();
6927 strings.insert("en".to_string(), en_strings);
6928
6929 if let Some(bridge) = backend
6931 .services
6932 .as_any()
6933 .downcast_ref::<TestServiceBridge>()
6934 {
6935 let mut en = bridge.en_strings.lock().unwrap();
6936 en.insert("greeting".to_string(), "Hello, World!".to_string());
6937 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6938 }
6939
6940 backend
6942 .execute_js(
6943 r#"
6944 const editor = getEditor();
6945 globalThis._greeting = editor.t("greeting");
6946 globalThis._prompt = editor.t("prompt.find_file");
6947 globalThis._missing = editor.t("nonexistent.key");
6948 "#,
6949 "test.js",
6950 )
6951 .unwrap();
6952
6953 backend
6954 .plugin_contexts
6955 .borrow()
6956 .get("test")
6957 .unwrap()
6958 .clone()
6959 .with(|ctx| {
6960 let global = ctx.globals();
6961 let greeting: String = global.get("_greeting").unwrap();
6962 assert_eq!(greeting, "Hello, World!");
6963
6964 let prompt: String = global.get("_prompt").unwrap();
6965 assert_eq!(prompt, "Find file: ");
6966
6967 let missing: String = global.get("_missing").unwrap();
6969 assert_eq!(missing, "nonexistent.key");
6970 });
6971 }
6972
6973 #[test]
6976 fn test_api_set_line_indicator() {
6977 let (mut backend, rx) = create_test_backend();
6978
6979 backend
6980 .execute_js(
6981 r#"
6982 const editor = getEditor();
6983 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6984 "#,
6985 "test.js",
6986 )
6987 .unwrap();
6988
6989 let cmd = rx.try_recv().unwrap();
6990 match cmd {
6991 PluginCommand::SetLineIndicator {
6992 buffer_id,
6993 line,
6994 namespace,
6995 symbol,
6996 color,
6997 priority,
6998 } => {
6999 assert_eq!(buffer_id.0, 1);
7000 assert_eq!(line, 5);
7001 assert_eq!(namespace, "test-ns");
7002 assert_eq!(symbol, "●");
7003 assert_eq!(color, (255, 0, 0));
7004 assert_eq!(priority, 10);
7005 }
7006 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
7007 }
7008 }
7009
7010 #[test]
7011 fn test_api_clear_line_indicators() {
7012 let (mut backend, rx) = create_test_backend();
7013
7014 backend
7015 .execute_js(
7016 r#"
7017 const editor = getEditor();
7018 editor.clearLineIndicators(1, "test-ns");
7019 "#,
7020 "test.js",
7021 )
7022 .unwrap();
7023
7024 let cmd = rx.try_recv().unwrap();
7025 match cmd {
7026 PluginCommand::ClearLineIndicators {
7027 buffer_id,
7028 namespace,
7029 } => {
7030 assert_eq!(buffer_id.0, 1);
7031 assert_eq!(namespace, "test-ns");
7032 }
7033 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
7034 }
7035 }
7036
7037 #[test]
7040 fn test_api_create_virtual_buffer_sends_command() {
7041 let (mut backend, rx) = create_test_backend();
7042
7043 backend
7044 .execute_js(
7045 r#"
7046 const editor = getEditor();
7047 editor.createVirtualBuffer({
7048 name: "*Test Buffer*",
7049 mode: "test-mode",
7050 readOnly: true,
7051 entries: [
7052 { text: "Line 1\n", properties: { type: "header" } },
7053 { text: "Line 2\n", properties: { type: "content" } }
7054 ],
7055 showLineNumbers: false,
7056 showCursors: true,
7057 editingDisabled: true
7058 });
7059 "#,
7060 "test.js",
7061 )
7062 .unwrap();
7063
7064 let cmd = rx.try_recv().unwrap();
7065 match cmd {
7066 PluginCommand::CreateVirtualBufferWithContent {
7067 name,
7068 mode,
7069 read_only,
7070 entries,
7071 show_line_numbers,
7072 show_cursors,
7073 editing_disabled,
7074 ..
7075 } => {
7076 assert_eq!(name, "*Test Buffer*");
7077 assert_eq!(mode, "test-mode");
7078 assert!(read_only);
7079 assert_eq!(entries.len(), 2);
7080 assert_eq!(entries[0].text, "Line 1\n");
7081 assert!(!show_line_numbers);
7082 assert!(show_cursors);
7083 assert!(editing_disabled);
7084 }
7085 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
7086 }
7087 }
7088
7089 #[test]
7090 fn test_api_set_virtual_buffer_content() {
7091 let (mut backend, rx) = create_test_backend();
7092
7093 backend
7094 .execute_js(
7095 r#"
7096 const editor = getEditor();
7097 editor.setVirtualBufferContent(5, [
7098 { text: "New content\n", properties: { type: "updated" } }
7099 ]);
7100 "#,
7101 "test.js",
7102 )
7103 .unwrap();
7104
7105 let cmd = rx.try_recv().unwrap();
7106 match cmd {
7107 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
7108 assert_eq!(buffer_id.0, 5);
7109 assert_eq!(entries.len(), 1);
7110 assert_eq!(entries[0].text, "New content\n");
7111 }
7112 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
7113 }
7114 }
7115
7116 #[test]
7119 fn test_api_add_overlay() {
7120 let (mut backend, rx) = create_test_backend();
7121
7122 backend
7123 .execute_js(
7124 r#"
7125 const editor = getEditor();
7126 editor.addOverlay(1, "highlight", 10, 20, {
7127 fg: [255, 128, 0],
7128 bg: [50, 50, 50],
7129 bold: true,
7130 });
7131 "#,
7132 "test.js",
7133 )
7134 .unwrap();
7135
7136 let cmd = rx.try_recv().unwrap();
7137 match cmd {
7138 PluginCommand::AddOverlay {
7139 buffer_id,
7140 namespace,
7141 range,
7142 options,
7143 } => {
7144 use fresh_core::api::OverlayColorSpec;
7145 assert_eq!(buffer_id.0, 1);
7146 assert!(namespace.is_some());
7147 assert_eq!(namespace.unwrap().as_str(), "highlight");
7148 assert_eq!(range, 10..20);
7149 assert!(matches!(
7150 options.fg,
7151 Some(OverlayColorSpec::Rgb(255, 128, 0))
7152 ));
7153 assert!(matches!(
7154 options.bg,
7155 Some(OverlayColorSpec::Rgb(50, 50, 50))
7156 ));
7157 assert!(!options.underline);
7158 assert!(options.bold);
7159 assert!(!options.italic);
7160 assert!(!options.extend_to_line_end);
7161 }
7162 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7163 }
7164 }
7165
7166 #[test]
7167 fn test_api_add_overlay_with_theme_keys() {
7168 let (mut backend, rx) = create_test_backend();
7169
7170 backend
7171 .execute_js(
7172 r#"
7173 const editor = getEditor();
7174 // Test with theme keys for colors
7175 editor.addOverlay(1, "themed", 0, 10, {
7176 fg: "ui.status_bar_fg",
7177 bg: "editor.selection_bg",
7178 });
7179 "#,
7180 "test.js",
7181 )
7182 .unwrap();
7183
7184 let cmd = rx.try_recv().unwrap();
7185 match cmd {
7186 PluginCommand::AddOverlay {
7187 buffer_id,
7188 namespace,
7189 range,
7190 options,
7191 } => {
7192 use fresh_core::api::OverlayColorSpec;
7193 assert_eq!(buffer_id.0, 1);
7194 assert!(namespace.is_some());
7195 assert_eq!(namespace.unwrap().as_str(), "themed");
7196 assert_eq!(range, 0..10);
7197 assert!(matches!(
7198 &options.fg,
7199 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
7200 ));
7201 assert!(matches!(
7202 &options.bg,
7203 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
7204 ));
7205 assert!(!options.underline);
7206 assert!(!options.bold);
7207 assert!(!options.italic);
7208 assert!(!options.extend_to_line_end);
7209 }
7210 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7211 }
7212 }
7213
7214 #[test]
7215 fn test_api_clear_namespace() {
7216 let (mut backend, rx) = create_test_backend();
7217
7218 backend
7219 .execute_js(
7220 r#"
7221 const editor = getEditor();
7222 editor.clearNamespace(1, "highlight");
7223 "#,
7224 "test.js",
7225 )
7226 .unwrap();
7227
7228 let cmd = rx.try_recv().unwrap();
7229 match cmd {
7230 PluginCommand::ClearNamespace {
7231 buffer_id,
7232 namespace,
7233 } => {
7234 assert_eq!(buffer_id.0, 1);
7235 assert_eq!(namespace.as_str(), "highlight");
7236 }
7237 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
7238 }
7239 }
7240
7241 #[test]
7244 fn test_api_get_theme_schema() {
7245 let (mut backend, _rx) = create_test_backend();
7246
7247 backend
7248 .execute_js(
7249 r#"
7250 const editor = getEditor();
7251 const schema = editor.getThemeSchema();
7252 globalThis._isObject = typeof schema === 'object' && schema !== null;
7253 "#,
7254 "test.js",
7255 )
7256 .unwrap();
7257
7258 backend
7259 .plugin_contexts
7260 .borrow()
7261 .get("test")
7262 .unwrap()
7263 .clone()
7264 .with(|ctx| {
7265 let global = ctx.globals();
7266 let is_object: bool = global.get("_isObject").unwrap();
7267 assert!(is_object);
7269 });
7270 }
7271
7272 #[test]
7273 fn test_api_get_builtin_themes() {
7274 let (mut backend, _rx) = create_test_backend();
7275
7276 backend
7277 .execute_js(
7278 r#"
7279 const editor = getEditor();
7280 const themes = editor.getBuiltinThemes();
7281 globalThis._isObject = typeof themes === 'object' && themes !== null;
7282 "#,
7283 "test.js",
7284 )
7285 .unwrap();
7286
7287 backend
7288 .plugin_contexts
7289 .borrow()
7290 .get("test")
7291 .unwrap()
7292 .clone()
7293 .with(|ctx| {
7294 let global = ctx.globals();
7295 let is_object: bool = global.get("_isObject").unwrap();
7296 assert!(is_object);
7298 });
7299 }
7300
7301 #[test]
7302 fn test_api_apply_theme() {
7303 let (mut backend, rx) = create_test_backend();
7304
7305 backend
7306 .execute_js(
7307 r#"
7308 const editor = getEditor();
7309 editor.applyTheme("dark");
7310 "#,
7311 "test.js",
7312 )
7313 .unwrap();
7314
7315 let cmd = rx.try_recv().unwrap();
7316 match cmd {
7317 PluginCommand::ApplyTheme { theme_name } => {
7318 assert_eq!(theme_name, "dark");
7319 }
7320 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
7321 }
7322 }
7323
7324 #[test]
7325 fn test_api_override_theme_colors_round_trip() {
7326 let (mut backend, rx) = create_test_backend();
7329
7330 backend
7331 .execute_js(
7332 r#"
7333 const editor = getEditor();
7334 editor.overrideThemeColors({
7335 "editor.bg": [10, 20, 30],
7336 "editor.fg": [220, 221, 222],
7337 });
7338 "#,
7339 "test.js",
7340 )
7341 .unwrap();
7342
7343 let cmd = rx.try_recv().unwrap();
7344 match cmd {
7345 PluginCommand::OverrideThemeColors { overrides } => {
7346 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
7347 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
7348 assert_eq!(overrides.len(), 2);
7349 }
7350 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
7351 }
7352 }
7353
7354 #[test]
7355 fn test_api_override_theme_colors_clamps_out_of_range() {
7356 let (mut backend, rx) = create_test_backend();
7357
7358 backend
7359 .execute_js(
7360 r#"
7361 const editor = getEditor();
7362 editor.overrideThemeColors({
7363 "editor.bg": [-5, 300, 128],
7364 });
7365 "#,
7366 "test.js",
7367 )
7368 .unwrap();
7369
7370 match rx.try_recv().unwrap() {
7371 PluginCommand::OverrideThemeColors { overrides } => {
7372 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
7373 }
7374 other => panic!("Expected OverrideThemeColors, got {other:?}"),
7375 }
7376 }
7377
7378 #[test]
7379 fn test_api_override_theme_colors_drops_malformed_entries() {
7380 let (mut backend, rx) = create_test_backend();
7383
7384 backend
7385 .execute_js(
7386 r#"
7387 const editor = getEditor();
7388 editor.overrideThemeColors({
7389 "editor.bg": [1, 2, 3],
7390 "not_an_array": "oops",
7391 "wrong_length": [1, 2],
7392 "floats_are_fine": [10.7, 20.2, 30.9],
7393 });
7394 "#,
7395 "test.js",
7396 )
7397 .unwrap();
7398
7399 match rx.try_recv().unwrap() {
7400 PluginCommand::OverrideThemeColors { overrides } => {
7401 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
7402 assert!(!overrides.contains_key("not_an_array"));
7403 assert!(!overrides.contains_key("wrong_length"));
7404 assert_eq!(
7406 overrides.get("floats_are_fine").copied(),
7407 Some([10, 20, 30])
7408 );
7409 }
7410 other => panic!("Expected OverrideThemeColors, got {other:?}"),
7411 }
7412 }
7413
7414 #[test]
7415 fn test_api_get_theme_data_missing() {
7416 let (mut backend, _rx) = create_test_backend();
7417
7418 backend
7419 .execute_js(
7420 r#"
7421 const editor = getEditor();
7422 const data = editor.getThemeData("nonexistent");
7423 globalThis._isNull = data === null;
7424 "#,
7425 "test.js",
7426 )
7427 .unwrap();
7428
7429 backend
7430 .plugin_contexts
7431 .borrow()
7432 .get("test")
7433 .unwrap()
7434 .clone()
7435 .with(|ctx| {
7436 let global = ctx.globals();
7437 let is_null: bool = global.get("_isNull").unwrap();
7438 assert!(is_null);
7440 });
7441 }
7442
7443 #[test]
7444 fn test_api_get_theme_data_present() {
7445 let (tx, _rx) = mpsc::channel();
7447 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7448 let services = Arc::new(ThemeCacheTestBridge {
7449 inner: TestServiceBridge::new(),
7450 });
7451 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7452
7453 backend
7454 .execute_js(
7455 r#"
7456 const editor = getEditor();
7457 const data = editor.getThemeData("test-theme");
7458 globalThis._hasData = data !== null && typeof data === 'object';
7459 globalThis._name = data ? data.name : null;
7460 "#,
7461 "test.js",
7462 )
7463 .unwrap();
7464
7465 backend
7466 .plugin_contexts
7467 .borrow()
7468 .get("test")
7469 .unwrap()
7470 .clone()
7471 .with(|ctx| {
7472 let global = ctx.globals();
7473 let has_data: bool = global.get("_hasData").unwrap();
7474 assert!(has_data, "getThemeData should return theme object");
7475 let name: String = global.get("_name").unwrap();
7476 assert_eq!(name, "test-theme");
7477 });
7478 }
7479
7480 #[test]
7481 fn test_api_theme_file_exists() {
7482 let (mut backend, _rx) = create_test_backend();
7483
7484 backend
7485 .execute_js(
7486 r#"
7487 const editor = getEditor();
7488 globalThis._exists = editor.themeFileExists("anything");
7489 "#,
7490 "test.js",
7491 )
7492 .unwrap();
7493
7494 backend
7495 .plugin_contexts
7496 .borrow()
7497 .get("test")
7498 .unwrap()
7499 .clone()
7500 .with(|ctx| {
7501 let global = ctx.globals();
7502 let exists: bool = global.get("_exists").unwrap();
7503 assert!(!exists);
7505 });
7506 }
7507
7508 #[test]
7509 fn test_api_save_theme_file_error() {
7510 let (mut backend, _rx) = create_test_backend();
7511
7512 backend
7513 .execute_js(
7514 r#"
7515 const editor = getEditor();
7516 let threw = false;
7517 try {
7518 editor.saveThemeFile("test", "{}");
7519 } catch (e) {
7520 threw = true;
7521 }
7522 globalThis._threw = threw;
7523 "#,
7524 "test.js",
7525 )
7526 .unwrap();
7527
7528 backend
7529 .plugin_contexts
7530 .borrow()
7531 .get("test")
7532 .unwrap()
7533 .clone()
7534 .with(|ctx| {
7535 let global = ctx.globals();
7536 let threw: bool = global.get("_threw").unwrap();
7537 assert!(threw);
7539 });
7540 }
7541
7542 struct ThemeCacheTestBridge {
7544 inner: TestServiceBridge,
7545 }
7546
7547 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
7548 fn as_any(&self) -> &dyn std::any::Any {
7549 self
7550 }
7551 fn translate(
7552 &self,
7553 plugin_name: &str,
7554 key: &str,
7555 args: &HashMap<String, String>,
7556 ) -> String {
7557 self.inner.translate(plugin_name, key, args)
7558 }
7559 fn current_locale(&self) -> String {
7560 self.inner.current_locale()
7561 }
7562 fn set_js_execution_state(&self, state: String) {
7563 self.inner.set_js_execution_state(state);
7564 }
7565 fn clear_js_execution_state(&self) {
7566 self.inner.clear_js_execution_state();
7567 }
7568 fn get_theme_schema(&self) -> serde_json::Value {
7569 self.inner.get_theme_schema()
7570 }
7571 fn get_builtin_themes(&self) -> serde_json::Value {
7572 self.inner.get_builtin_themes()
7573 }
7574 fn register_command(&self, command: fresh_core::command::Command) {
7575 self.inner.register_command(command);
7576 }
7577 fn unregister_command(&self, name: &str) {
7578 self.inner.unregister_command(name);
7579 }
7580 fn unregister_commands_by_prefix(&self, prefix: &str) {
7581 self.inner.unregister_commands_by_prefix(prefix);
7582 }
7583 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
7584 self.inner.unregister_commands_by_plugin(plugin_name);
7585 }
7586 fn plugins_dir(&self) -> std::path::PathBuf {
7587 self.inner.plugins_dir()
7588 }
7589 fn config_dir(&self) -> std::path::PathBuf {
7590 self.inner.config_dir()
7591 }
7592 fn data_dir(&self) -> std::path::PathBuf {
7593 self.inner.data_dir()
7594 }
7595 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
7596 if name == "test-theme" {
7597 Some(serde_json::json!({
7598 "name": "test-theme",
7599 "editor": {},
7600 "ui": {},
7601 "syntax": {}
7602 }))
7603 } else {
7604 None
7605 }
7606 }
7607 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7608 Err("test bridge does not support save".to_string())
7609 }
7610 fn theme_file_exists(&self, name: &str) -> bool {
7611 name == "test-theme"
7612 }
7613 }
7614
7615 #[test]
7618 fn test_api_close_buffer() {
7619 let (mut backend, rx) = create_test_backend();
7620
7621 backend
7622 .execute_js(
7623 r#"
7624 const editor = getEditor();
7625 editor.closeBuffer(3);
7626 "#,
7627 "test.js",
7628 )
7629 .unwrap();
7630
7631 let cmd = rx.try_recv().unwrap();
7632 match cmd {
7633 PluginCommand::CloseBuffer { buffer_id } => {
7634 assert_eq!(buffer_id.0, 3);
7635 }
7636 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
7637 }
7638 }
7639
7640 #[test]
7641 fn test_api_focus_split() {
7642 let (mut backend, rx) = create_test_backend();
7643
7644 backend
7645 .execute_js(
7646 r#"
7647 const editor = getEditor();
7648 editor.focusSplit(2);
7649 "#,
7650 "test.js",
7651 )
7652 .unwrap();
7653
7654 let cmd = rx.try_recv().unwrap();
7655 match cmd {
7656 PluginCommand::FocusSplit { split_id } => {
7657 assert_eq!(split_id.0, 2);
7658 }
7659 _ => panic!("Expected FocusSplit, got {:?}", cmd),
7660 }
7661 }
7662
7663 #[test]
7664 fn test_api_list_buffers() {
7665 let (tx, _rx) = mpsc::channel();
7666 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7667
7668 {
7670 let mut state = state_snapshot.write().unwrap();
7671 state.buffers.insert(
7672 BufferId(0),
7673 BufferInfo {
7674 id: BufferId(0),
7675 path: Some(PathBuf::from("/test1.txt")),
7676 modified: false,
7677 length: 100,
7678 is_virtual: false,
7679 view_mode: "source".to_string(),
7680 is_composing_in_any_split: false,
7681 compose_width: None,
7682 language: "text".to_string(),
7683 is_preview: false,
7684 splits: Vec::new(),
7685 },
7686 );
7687 state.buffers.insert(
7688 BufferId(1),
7689 BufferInfo {
7690 id: BufferId(1),
7691 path: Some(PathBuf::from("/test2.txt")),
7692 modified: true,
7693 length: 200,
7694 is_virtual: false,
7695 view_mode: "source".to_string(),
7696 is_composing_in_any_split: false,
7697 compose_width: None,
7698 language: "text".to_string(),
7699 is_preview: false,
7700 splits: Vec::new(),
7701 },
7702 );
7703 }
7704
7705 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7706 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7707
7708 backend
7709 .execute_js(
7710 r#"
7711 const editor = getEditor();
7712 const buffers = editor.listBuffers();
7713 globalThis._isArray = Array.isArray(buffers);
7714 globalThis._length = buffers.length;
7715 "#,
7716 "test.js",
7717 )
7718 .unwrap();
7719
7720 backend
7721 .plugin_contexts
7722 .borrow()
7723 .get("test")
7724 .unwrap()
7725 .clone()
7726 .with(|ctx| {
7727 let global = ctx.globals();
7728 let is_array: bool = global.get("_isArray").unwrap();
7729 let length: u32 = global.get("_length").unwrap();
7730 assert!(is_array);
7731 assert_eq!(length, 2);
7732 });
7733 }
7734
7735 #[test]
7738 fn test_api_start_prompt() {
7739 let (mut backend, rx) = create_test_backend();
7740
7741 backend
7742 .execute_js(
7743 r#"
7744 const editor = getEditor();
7745 editor.startPrompt("Enter value:", "test-prompt");
7746 "#,
7747 "test.js",
7748 )
7749 .unwrap();
7750
7751 let cmd = rx.try_recv().unwrap();
7752 match cmd {
7753 PluginCommand::StartPrompt { label, prompt_type } => {
7754 assert_eq!(label, "Enter value:");
7755 assert_eq!(prompt_type, "test-prompt");
7756 }
7757 _ => panic!("Expected StartPrompt, got {:?}", cmd),
7758 }
7759 }
7760
7761 #[test]
7762 fn test_api_start_prompt_with_initial() {
7763 let (mut backend, rx) = create_test_backend();
7764
7765 backend
7766 .execute_js(
7767 r#"
7768 const editor = getEditor();
7769 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
7770 "#,
7771 "test.js",
7772 )
7773 .unwrap();
7774
7775 let cmd = rx.try_recv().unwrap();
7776 match cmd {
7777 PluginCommand::StartPromptWithInitial {
7778 label,
7779 prompt_type,
7780 initial_value,
7781 } => {
7782 assert_eq!(label, "Enter value:");
7783 assert_eq!(prompt_type, "test-prompt");
7784 assert_eq!(initial_value, "default");
7785 }
7786 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
7787 }
7788 }
7789
7790 #[test]
7791 fn test_api_set_prompt_suggestions() {
7792 let (mut backend, rx) = create_test_backend();
7793
7794 backend
7795 .execute_js(
7796 r#"
7797 const editor = getEditor();
7798 editor.setPromptSuggestions([
7799 { text: "Option 1", value: "opt1" },
7800 { text: "Option 2", value: "opt2" }
7801 ]);
7802 "#,
7803 "test.js",
7804 )
7805 .unwrap();
7806
7807 let cmd = rx.try_recv().unwrap();
7808 match cmd {
7809 PluginCommand::SetPromptSuggestions { suggestions } => {
7810 assert_eq!(suggestions.len(), 2);
7811 assert_eq!(suggestions[0].text, "Option 1");
7812 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
7813 }
7814 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
7815 }
7816 }
7817
7818 #[test]
7821 fn test_api_get_active_buffer_id() {
7822 let (tx, _rx) = mpsc::channel();
7823 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7824
7825 {
7826 let mut state = state_snapshot.write().unwrap();
7827 state.active_buffer_id = BufferId(42);
7828 }
7829
7830 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7831 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7832
7833 backend
7834 .execute_js(
7835 r#"
7836 const editor = getEditor();
7837 globalThis._activeId = editor.getActiveBufferId();
7838 "#,
7839 "test.js",
7840 )
7841 .unwrap();
7842
7843 backend
7844 .plugin_contexts
7845 .borrow()
7846 .get("test")
7847 .unwrap()
7848 .clone()
7849 .with(|ctx| {
7850 let global = ctx.globals();
7851 let result: u32 = global.get("_activeId").unwrap();
7852 assert_eq!(result, 42);
7853 });
7854 }
7855
7856 #[test]
7857 fn test_api_get_active_split_id() {
7858 let (tx, _rx) = mpsc::channel();
7859 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7860
7861 {
7862 let mut state = state_snapshot.write().unwrap();
7863 state.active_split_id = 7;
7864 }
7865
7866 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7867 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7868
7869 backend
7870 .execute_js(
7871 r#"
7872 const editor = getEditor();
7873 globalThis._splitId = editor.getActiveSplitId();
7874 "#,
7875 "test.js",
7876 )
7877 .unwrap();
7878
7879 backend
7880 .plugin_contexts
7881 .borrow()
7882 .get("test")
7883 .unwrap()
7884 .clone()
7885 .with(|ctx| {
7886 let global = ctx.globals();
7887 let result: u32 = global.get("_splitId").unwrap();
7888 assert_eq!(result, 7);
7889 });
7890 }
7891
7892 #[test]
7895 fn test_api_file_exists() {
7896 let (mut backend, _rx) = create_test_backend();
7897
7898 backend
7899 .execute_js(
7900 r#"
7901 const editor = getEditor();
7902 // Test with a path that definitely exists
7903 globalThis._exists = editor.fileExists("/");
7904 "#,
7905 "test.js",
7906 )
7907 .unwrap();
7908
7909 backend
7910 .plugin_contexts
7911 .borrow()
7912 .get("test")
7913 .unwrap()
7914 .clone()
7915 .with(|ctx| {
7916 let global = ctx.globals();
7917 let result: bool = global.get("_exists").unwrap();
7918 assert!(result);
7919 });
7920 }
7921
7922 #[test]
7923 fn test_api_parse_jsonc() {
7924 let (mut backend, _rx) = create_test_backend();
7925
7926 backend
7927 .execute_js(
7928 r#"
7929 const editor = getEditor();
7930 // Comments, trailing commas, and nested structures should all parse.
7931 const parsed = editor.parseJsonc(`{
7932 // name of the container
7933 "name": "test",
7934 "features": {
7935 "docker-in-docker": {},
7936 },
7937 /* forwarded port list */
7938 "forwardPorts": [3000, 8080,],
7939 }`);
7940 globalThis._name = parsed.name;
7941 globalThis._featureCount = Object.keys(parsed.features).length;
7942 globalThis._portCount = parsed.forwardPorts.length;
7943
7944 // Invalid JSONC should throw.
7945 try {
7946 editor.parseJsonc("{ broken");
7947 globalThis._threw = false;
7948 } catch (_e) {
7949 globalThis._threw = true;
7950 }
7951 "#,
7952 "test.js",
7953 )
7954 .unwrap();
7955
7956 backend
7957 .plugin_contexts
7958 .borrow()
7959 .get("test")
7960 .unwrap()
7961 .clone()
7962 .with(|ctx| {
7963 let global = ctx.globals();
7964 let name: String = global.get("_name").unwrap();
7965 let feature_count: u32 = global.get("_featureCount").unwrap();
7966 let port_count: u32 = global.get("_portCount").unwrap();
7967 let threw: bool = global.get("_threw").unwrap();
7968 assert_eq!(name, "test");
7969 assert_eq!(feature_count, 1);
7970 assert_eq!(port_count, 2);
7971 assert!(threw, "Invalid JSONC should throw");
7972 });
7973 }
7974
7975 #[test]
7976 fn test_api_get_cwd() {
7977 let (mut backend, _rx) = create_test_backend();
7978
7979 backend
7980 .execute_js(
7981 r#"
7982 const editor = getEditor();
7983 globalThis._cwd = editor.getCwd();
7984 "#,
7985 "test.js",
7986 )
7987 .unwrap();
7988
7989 backend
7990 .plugin_contexts
7991 .borrow()
7992 .get("test")
7993 .unwrap()
7994 .clone()
7995 .with(|ctx| {
7996 let global = ctx.globals();
7997 let result: String = global.get("_cwd").unwrap();
7998 assert!(!result.is_empty());
8000 });
8001 }
8002
8003 #[test]
8004 fn test_api_get_env() {
8005 let (mut backend, _rx) = create_test_backend();
8006
8007 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
8009
8010 backend
8011 .execute_js(
8012 r#"
8013 const editor = getEditor();
8014 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
8015 "#,
8016 "test.js",
8017 )
8018 .unwrap();
8019
8020 backend
8021 .plugin_contexts
8022 .borrow()
8023 .get("test")
8024 .unwrap()
8025 .clone()
8026 .with(|ctx| {
8027 let global = ctx.globals();
8028 let result: Option<String> = global.get("_envVal").unwrap();
8029 assert_eq!(result, Some("test_value".to_string()));
8030 });
8031
8032 std::env::remove_var("TEST_PLUGIN_VAR");
8033 }
8034
8035 #[test]
8036 fn test_api_get_config() {
8037 let (mut backend, _rx) = create_test_backend();
8038
8039 backend
8040 .execute_js(
8041 r#"
8042 const editor = getEditor();
8043 const config = editor.getConfig();
8044 globalThis._isObject = typeof config === 'object';
8045 "#,
8046 "test.js",
8047 )
8048 .unwrap();
8049
8050 backend
8051 .plugin_contexts
8052 .borrow()
8053 .get("test")
8054 .unwrap()
8055 .clone()
8056 .with(|ctx| {
8057 let global = ctx.globals();
8058 let is_object: bool = global.get("_isObject").unwrap();
8059 assert!(is_object);
8061 });
8062 }
8063
8064 #[test]
8065 fn test_api_get_themes_dir() {
8066 let (mut backend, _rx) = create_test_backend();
8067
8068 backend
8069 .execute_js(
8070 r#"
8071 const editor = getEditor();
8072 globalThis._themesDir = editor.getThemesDir();
8073 "#,
8074 "test.js",
8075 )
8076 .unwrap();
8077
8078 backend
8079 .plugin_contexts
8080 .borrow()
8081 .get("test")
8082 .unwrap()
8083 .clone()
8084 .with(|ctx| {
8085 let global = ctx.globals();
8086 let result: String = global.get("_themesDir").unwrap();
8087 assert!(!result.is_empty());
8089 });
8090 }
8091
8092 #[test]
8095 fn test_api_read_dir() {
8096 let (mut backend, _rx) = create_test_backend();
8097
8098 backend
8099 .execute_js(
8100 r#"
8101 const editor = getEditor();
8102 const entries = editor.readDir("/tmp");
8103 globalThis._isArray = Array.isArray(entries);
8104 globalThis._length = entries.length;
8105 "#,
8106 "test.js",
8107 )
8108 .unwrap();
8109
8110 backend
8111 .plugin_contexts
8112 .borrow()
8113 .get("test")
8114 .unwrap()
8115 .clone()
8116 .with(|ctx| {
8117 let global = ctx.globals();
8118 let is_array: bool = global.get("_isArray").unwrap();
8119 let length: u32 = global.get("_length").unwrap();
8120 assert!(is_array);
8122 let _ = length;
8124 });
8125 }
8126
8127 #[test]
8130 fn test_api_execute_action() {
8131 let (mut backend, rx) = create_test_backend();
8132
8133 backend
8134 .execute_js(
8135 r#"
8136 const editor = getEditor();
8137 editor.executeAction("move_cursor_up");
8138 "#,
8139 "test.js",
8140 )
8141 .unwrap();
8142
8143 let cmd = rx.try_recv().unwrap();
8144 match cmd {
8145 PluginCommand::ExecuteAction { action_name } => {
8146 assert_eq!(action_name, "move_cursor_up");
8147 }
8148 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
8149 }
8150 }
8151
8152 #[test]
8155 fn test_api_debug() {
8156 let (mut backend, _rx) = create_test_backend();
8157
8158 backend
8160 .execute_js(
8161 r#"
8162 const editor = getEditor();
8163 editor.debug("Test debug message");
8164 editor.debug("Another message with special chars: <>&\"'");
8165 "#,
8166 "test.js",
8167 )
8168 .unwrap();
8169 }
8171
8172 #[test]
8175 fn test_typescript_preamble_generated() {
8176 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
8178 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
8179 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
8180 println!(
8181 "Generated {} bytes of TypeScript preamble",
8182 JSEDITORAPI_TS_PREAMBLE.len()
8183 );
8184 }
8185
8186 #[test]
8187 fn test_typescript_editor_api_generated() {
8188 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
8190 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
8191 println!(
8192 "Generated {} bytes of EditorAPI interface",
8193 JSEDITORAPI_TS_EDITOR_API.len()
8194 );
8195 }
8196
8197 #[test]
8198 fn test_js_methods_list() {
8199 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
8201 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
8202 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
8204 if i < 20 {
8205 println!(" - {}", method);
8206 }
8207 }
8208 if JSEDITORAPI_JS_METHODS.len() > 20 {
8209 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
8210 }
8211 }
8212
8213 #[test]
8216 fn test_api_load_plugin_sends_command() {
8217 let (mut backend, rx) = create_test_backend();
8218
8219 backend
8221 .execute_js(
8222 r#"
8223 const editor = getEditor();
8224 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
8225 "#,
8226 "test.js",
8227 )
8228 .unwrap();
8229
8230 let cmd = rx.try_recv().unwrap();
8232 match cmd {
8233 PluginCommand::LoadPlugin { path, callback_id } => {
8234 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
8235 assert!(callback_id.0 > 0); }
8237 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
8238 }
8239 }
8240
8241 #[test]
8242 fn test_api_unload_plugin_sends_command() {
8243 let (mut backend, rx) = create_test_backend();
8244
8245 backend
8247 .execute_js(
8248 r#"
8249 const editor = getEditor();
8250 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
8251 "#,
8252 "test.js",
8253 )
8254 .unwrap();
8255
8256 let cmd = rx.try_recv().unwrap();
8258 match cmd {
8259 PluginCommand::UnloadPlugin { name, callback_id } => {
8260 assert_eq!(name, "my-plugin");
8261 assert!(callback_id.0 > 0); }
8263 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
8264 }
8265 }
8266
8267 #[test]
8268 fn test_api_reload_plugin_sends_command() {
8269 let (mut backend, rx) = create_test_backend();
8270
8271 backend
8273 .execute_js(
8274 r#"
8275 const editor = getEditor();
8276 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
8277 "#,
8278 "test.js",
8279 )
8280 .unwrap();
8281
8282 let cmd = rx.try_recv().unwrap();
8284 match cmd {
8285 PluginCommand::ReloadPlugin { name, callback_id } => {
8286 assert_eq!(name, "my-plugin");
8287 assert!(callback_id.0 > 0); }
8289 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
8290 }
8291 }
8292
8293 #[test]
8294 fn test_api_load_plugin_resolves_callback() {
8295 let (mut backend, rx) = create_test_backend();
8296
8297 backend
8299 .execute_js(
8300 r#"
8301 const editor = getEditor();
8302 globalThis._loadResult = null;
8303 editor.loadPlugin("/path/to/plugin.ts").then(result => {
8304 globalThis._loadResult = result;
8305 });
8306 "#,
8307 "test.js",
8308 )
8309 .unwrap();
8310
8311 let callback_id = match rx.try_recv().unwrap() {
8313 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
8314 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
8315 };
8316
8317 backend.resolve_callback(callback_id, "true");
8319
8320 backend
8322 .plugin_contexts
8323 .borrow()
8324 .get("test")
8325 .unwrap()
8326 .clone()
8327 .with(|ctx| {
8328 run_pending_jobs_checked(&ctx, "test async loadPlugin");
8329 });
8330
8331 backend
8333 .plugin_contexts
8334 .borrow()
8335 .get("test")
8336 .unwrap()
8337 .clone()
8338 .with(|ctx| {
8339 let global = ctx.globals();
8340 let result: bool = global.get("_loadResult").unwrap();
8341 assert!(result);
8342 });
8343 }
8344
8345 #[test]
8346 fn test_api_version() {
8347 let (mut backend, _rx) = create_test_backend();
8348
8349 backend
8350 .execute_js(
8351 r#"
8352 const editor = getEditor();
8353 globalThis._apiVersion = editor.apiVersion();
8354 "#,
8355 "test.js",
8356 )
8357 .unwrap();
8358
8359 backend
8360 .plugin_contexts
8361 .borrow()
8362 .get("test")
8363 .unwrap()
8364 .clone()
8365 .with(|ctx| {
8366 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
8367 assert_eq!(version, 2);
8368 });
8369 }
8370
8371 #[test]
8372 fn test_api_unload_plugin_rejects_on_error() {
8373 let (mut backend, rx) = create_test_backend();
8374
8375 backend
8377 .execute_js(
8378 r#"
8379 const editor = getEditor();
8380 globalThis._unloadError = null;
8381 editor.unloadPlugin("nonexistent-plugin").catch(err => {
8382 globalThis._unloadError = err.message || String(err);
8383 });
8384 "#,
8385 "test.js",
8386 )
8387 .unwrap();
8388
8389 let callback_id = match rx.try_recv().unwrap() {
8391 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
8392 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
8393 };
8394
8395 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
8397
8398 backend
8400 .plugin_contexts
8401 .borrow()
8402 .get("test")
8403 .unwrap()
8404 .clone()
8405 .with(|ctx| {
8406 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
8407 });
8408
8409 backend
8411 .plugin_contexts
8412 .borrow()
8413 .get("test")
8414 .unwrap()
8415 .clone()
8416 .with(|ctx| {
8417 let global = ctx.globals();
8418 let error: String = global.get("_unloadError").unwrap();
8419 assert!(error.contains("nonexistent-plugin"));
8420 });
8421 }
8422
8423 #[test]
8424 fn test_api_set_global_state() {
8425 let (mut backend, rx) = create_test_backend();
8426
8427 backend
8428 .execute_js(
8429 r#"
8430 const editor = getEditor();
8431 editor.setGlobalState("myKey", { enabled: true, count: 42 });
8432 "#,
8433 "test_plugin.js",
8434 )
8435 .unwrap();
8436
8437 let cmd = rx.try_recv().unwrap();
8438 match cmd {
8439 PluginCommand::SetGlobalState {
8440 plugin_name,
8441 key,
8442 value,
8443 } => {
8444 assert_eq!(plugin_name, "test_plugin");
8445 assert_eq!(key, "myKey");
8446 let v = value.unwrap();
8447 assert_eq!(v["enabled"], serde_json::json!(true));
8448 assert_eq!(v["count"], serde_json::json!(42));
8449 }
8450 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
8451 }
8452 }
8453
8454 #[test]
8455 fn test_api_set_global_state_delete() {
8456 let (mut backend, rx) = create_test_backend();
8457
8458 backend
8459 .execute_js(
8460 r#"
8461 const editor = getEditor();
8462 editor.setGlobalState("myKey", null);
8463 "#,
8464 "test_plugin.js",
8465 )
8466 .unwrap();
8467
8468 let cmd = rx.try_recv().unwrap();
8469 match cmd {
8470 PluginCommand::SetGlobalState {
8471 plugin_name,
8472 key,
8473 value,
8474 } => {
8475 assert_eq!(plugin_name, "test_plugin");
8476 assert_eq!(key, "myKey");
8477 assert!(value.is_none(), "null should delete the key");
8478 }
8479 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
8480 }
8481 }
8482
8483 #[test]
8484 fn test_api_get_global_state_roundtrip() {
8485 let (mut backend, _rx) = create_test_backend();
8486
8487 backend
8489 .execute_js(
8490 r#"
8491 const editor = getEditor();
8492 editor.setGlobalState("flag", true);
8493 globalThis._result = editor.getGlobalState("flag");
8494 "#,
8495 "test_plugin.js",
8496 )
8497 .unwrap();
8498
8499 backend
8500 .plugin_contexts
8501 .borrow()
8502 .get("test_plugin")
8503 .unwrap()
8504 .clone()
8505 .with(|ctx| {
8506 let global = ctx.globals();
8507 let result: bool = global.get("_result").unwrap();
8508 assert!(
8509 result,
8510 "getGlobalState should return the value set by setGlobalState"
8511 );
8512 });
8513 }
8514
8515 #[test]
8516 fn test_api_get_global_state_missing_key() {
8517 let (mut backend, _rx) = create_test_backend();
8518
8519 backend
8520 .execute_js(
8521 r#"
8522 const editor = getEditor();
8523 globalThis._result = editor.getGlobalState("nonexistent");
8524 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
8525 "#,
8526 "test_plugin.js",
8527 )
8528 .unwrap();
8529
8530 backend
8531 .plugin_contexts
8532 .borrow()
8533 .get("test_plugin")
8534 .unwrap()
8535 .clone()
8536 .with(|ctx| {
8537 let global = ctx.globals();
8538 let is_undefined: bool = global.get("_isUndefined").unwrap();
8539 assert!(
8540 is_undefined,
8541 "getGlobalState for missing key should return undefined"
8542 );
8543 });
8544 }
8545
8546 #[test]
8547 fn test_api_global_state_isolation_between_plugins() {
8548 let (tx, _rx) = mpsc::channel();
8550 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8551 let services = Arc::new(TestServiceBridge::new());
8552
8553 let mut backend_a =
8555 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
8556 .unwrap();
8557 backend_a
8558 .execute_js(
8559 r#"
8560 const editor = getEditor();
8561 editor.setGlobalState("flag", "from_plugin_a");
8562 "#,
8563 "plugin_a.js",
8564 )
8565 .unwrap();
8566
8567 let mut backend_b =
8569 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
8570 .unwrap();
8571 backend_b
8572 .execute_js(
8573 r#"
8574 const editor = getEditor();
8575 editor.setGlobalState("flag", "from_plugin_b");
8576 "#,
8577 "plugin_b.js",
8578 )
8579 .unwrap();
8580
8581 backend_a
8583 .execute_js(
8584 r#"
8585 const editor = getEditor();
8586 globalThis._aValue = editor.getGlobalState("flag");
8587 "#,
8588 "plugin_a.js",
8589 )
8590 .unwrap();
8591
8592 backend_a
8593 .plugin_contexts
8594 .borrow()
8595 .get("plugin_a")
8596 .unwrap()
8597 .clone()
8598 .with(|ctx| {
8599 let global = ctx.globals();
8600 let a_value: String = global.get("_aValue").unwrap();
8601 assert_eq!(
8602 a_value, "from_plugin_a",
8603 "Plugin A should see its own value, not plugin B's"
8604 );
8605 });
8606
8607 backend_b
8609 .execute_js(
8610 r#"
8611 const editor = getEditor();
8612 globalThis._bValue = editor.getGlobalState("flag");
8613 "#,
8614 "plugin_b.js",
8615 )
8616 .unwrap();
8617
8618 backend_b
8619 .plugin_contexts
8620 .borrow()
8621 .get("plugin_b")
8622 .unwrap()
8623 .clone()
8624 .with(|ctx| {
8625 let global = ctx.globals();
8626 let b_value: String = global.get("_bValue").unwrap();
8627 assert_eq!(
8628 b_value, "from_plugin_b",
8629 "Plugin B should see its own value, not plugin A's"
8630 );
8631 });
8632 }
8633
8634 #[test]
8635 fn test_register_command_collision_different_plugins() {
8636 let (mut backend, _rx) = create_test_backend();
8637
8638 backend
8640 .execute_js(
8641 r#"
8642 const editor = getEditor();
8643 globalThis.handlerA = function() { };
8644 editor.registerCommand("My Command", "From A", "handlerA", null);
8645 "#,
8646 "plugin_a.js",
8647 )
8648 .unwrap();
8649
8650 let result = backend.execute_js(
8652 r#"
8653 const editor = getEditor();
8654 globalThis.handlerB = function() { };
8655 editor.registerCommand("My Command", "From B", "handlerB", null);
8656 "#,
8657 "plugin_b.js",
8658 );
8659
8660 assert!(
8661 result.is_err(),
8662 "Second plugin registering the same command name should fail"
8663 );
8664 let err_msg = result.unwrap_err().to_string();
8665 assert!(
8666 err_msg.contains("already registered"),
8667 "Error should mention collision: {}",
8668 err_msg
8669 );
8670 }
8671
8672 #[test]
8673 fn test_register_command_same_plugin_allowed() {
8674 let (mut backend, _rx) = create_test_backend();
8675
8676 backend
8678 .execute_js(
8679 r#"
8680 const editor = getEditor();
8681 globalThis.handler1 = function() { };
8682 editor.registerCommand("My Command", "Version 1", "handler1", null);
8683 globalThis.handler2 = function() { };
8684 editor.registerCommand("My Command", "Version 2", "handler2", null);
8685 "#,
8686 "plugin_a.js",
8687 )
8688 .unwrap();
8689 }
8690
8691 #[test]
8692 fn test_register_command_after_unregister() {
8693 let (mut backend, _rx) = create_test_backend();
8694
8695 backend
8697 .execute_js(
8698 r#"
8699 const editor = getEditor();
8700 globalThis.handlerA = function() { };
8701 editor.registerCommand("My Command", "From A", "handlerA", null);
8702 editor.unregisterCommand("My Command");
8703 "#,
8704 "plugin_a.js",
8705 )
8706 .unwrap();
8707
8708 backend
8710 .execute_js(
8711 r#"
8712 const editor = getEditor();
8713 globalThis.handlerB = function() { };
8714 editor.registerCommand("My Command", "From B", "handlerB", null);
8715 "#,
8716 "plugin_b.js",
8717 )
8718 .unwrap();
8719 }
8720
8721 #[test]
8722 fn test_register_command_collision_caught_in_try_catch() {
8723 let (mut backend, _rx) = create_test_backend();
8724
8725 backend
8727 .execute_js(
8728 r#"
8729 const editor = getEditor();
8730 globalThis.handlerA = function() { };
8731 editor.registerCommand("My Command", "From A", "handlerA", null);
8732 "#,
8733 "plugin_a.js",
8734 )
8735 .unwrap();
8736
8737 backend
8739 .execute_js(
8740 r#"
8741 const editor = getEditor();
8742 globalThis.handlerB = function() { };
8743 let caught = false;
8744 try {
8745 editor.registerCommand("My Command", "From B", "handlerB", null);
8746 } catch (e) {
8747 caught = true;
8748 }
8749 if (!caught) throw new Error("Expected collision error");
8750 "#,
8751 "plugin_b.js",
8752 )
8753 .unwrap();
8754 }
8755
8756 #[test]
8757 fn test_register_command_i18n_key_no_collision_across_plugins() {
8758 let (mut backend, _rx) = create_test_backend();
8759
8760 backend
8762 .execute_js(
8763 r#"
8764 const editor = getEditor();
8765 globalThis.handlerA = function() { };
8766 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
8767 "#,
8768 "plugin_a.js",
8769 )
8770 .unwrap();
8771
8772 backend
8775 .execute_js(
8776 r#"
8777 const editor = getEditor();
8778 globalThis.handlerB = function() { };
8779 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
8780 "#,
8781 "plugin_b.js",
8782 )
8783 .unwrap();
8784 }
8785
8786 #[test]
8787 fn test_register_command_non_i18n_still_collides() {
8788 let (mut backend, _rx) = create_test_backend();
8789
8790 backend
8792 .execute_js(
8793 r#"
8794 const editor = getEditor();
8795 globalThis.handlerA = function() { };
8796 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
8797 "#,
8798 "plugin_a.js",
8799 )
8800 .unwrap();
8801
8802 let result = backend.execute_js(
8804 r#"
8805 const editor = getEditor();
8806 globalThis.handlerB = function() { };
8807 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
8808 "#,
8809 "plugin_b.js",
8810 );
8811
8812 assert!(
8813 result.is_err(),
8814 "Non-%-prefixed names should still collide across plugins"
8815 );
8816 }
8817}