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}
623
624#[derive(Debug, Clone, Default)]
630pub struct PluginTrackedState {
631 pub overlay_namespaces: Vec<(BufferId, String)>,
633 pub virtual_line_namespaces: Vec<(BufferId, String)>,
635 pub line_indicator_namespaces: Vec<(BufferId, String)>,
637 pub virtual_text_ids: Vec<(BufferId, String)>,
639 pub file_explorer_namespaces: Vec<String>,
641 pub contexts_set: Vec<String>,
643 pub background_process_ids: Vec<u64>,
646 pub scroll_sync_group_ids: Vec<u32>,
648 pub virtual_buffer_ids: Vec<BufferId>,
650 pub composite_buffer_ids: Vec<BufferId>,
652 pub terminal_ids: Vec<fresh_core::TerminalId>,
654}
655
656pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
661
662#[derive(Debug, Clone)]
663pub struct PluginHandler {
664 pub plugin_name: String,
665 pub handler_name: String,
666}
667
668#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
671#[rquickjs::class]
672pub struct JsEditorApi {
673 #[qjs(skip_trace)]
674 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
675 #[qjs(skip_trace)]
676 command_sender: mpsc::Sender<PluginCommand>,
677 #[qjs(skip_trace)]
678 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
679 #[qjs(skip_trace)]
680 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
681 #[qjs(skip_trace)]
682 next_request_id: Rc<RefCell<u64>>,
683 #[qjs(skip_trace)]
684 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
685 #[qjs(skip_trace)]
686 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
687 #[qjs(skip_trace)]
688 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
689 #[qjs(skip_trace)]
690 async_resource_owners: AsyncResourceOwners,
691 #[qjs(skip_trace)]
693 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
694 #[qjs(skip_trace)]
696 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
697 #[qjs(skip_trace)]
699 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
700 #[qjs(skip_trace)]
702 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
703 pub plugin_name: String,
704}
705
706#[plugin_api_impl]
707#[rquickjs::methods(rename_all = "camelCase")]
708impl JsEditorApi {
709 pub fn api_version(&self) -> u32 {
714 2
715 }
716
717 pub fn get_active_buffer_id(&self) -> u32 {
719 self.state_snapshot
720 .read()
721 .map(|s| s.active_buffer_id.0 as u32)
722 .unwrap_or(0)
723 }
724
725 pub fn get_active_split_id(&self) -> u32 {
727 self.state_snapshot
728 .read()
729 .map(|s| s.active_split_id as u32)
730 .unwrap_or(0)
731 }
732
733 #[plugin_api(ts_return = "BufferInfo[]")]
735 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
736 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
737 s.buffers.values().cloned().collect()
738 } else {
739 Vec::new()
740 };
741 rquickjs_serde::to_value(ctx, &buffers)
742 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
743 }
744
745 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
747 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
748 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
749 s.available_grammars.clone()
750 } else {
751 Vec::new()
752 };
753 rquickjs_serde::to_value(ctx, &grammars)
754 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
755 }
756
757 pub fn debug(&self, msg: String) {
760 tracing::trace!("Plugin.debug: {}", msg);
761 }
762
763 pub fn info(&self, msg: String) {
764 tracing::info!("Plugin: {}", msg);
765 }
766
767 pub fn warn(&self, msg: String) {
768 tracing::warn!("Plugin: {}", msg);
769 }
770
771 pub fn error(&self, msg: String) {
772 tracing::error!("Plugin: {}", msg);
773 }
774
775 pub fn set_status(&self, msg: String) {
778 let _ = self
779 .command_sender
780 .send(PluginCommand::SetStatus { message: msg });
781 }
782
783 pub fn copy_to_clipboard(&self, text: String) {
786 let _ = self
787 .command_sender
788 .send(PluginCommand::SetClipboard { text });
789 }
790
791 pub fn set_clipboard(&self, text: String) {
792 let _ = self
793 .command_sender
794 .send(PluginCommand::SetClipboard { text });
795 }
796
797 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
802 if let Some(mode_name) = mode {
803 let key = format!("{}\0{}", action, mode_name);
804 if let Ok(snapshot) = self.state_snapshot.read() {
805 return snapshot.keybinding_labels.get(&key).cloned();
806 }
807 }
808 None
809 }
810
811 pub fn register_command<'js>(
822 &self,
823 ctx: rquickjs::Ctx<'js>,
824 name: String,
825 description: String,
826 handler_name: String,
827 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
828 rquickjs::Value<'js>,
829 >,
830 ) -> rquickjs::Result<bool> {
831 let plugin_name = self.plugin_name.clone();
833 let context_str: Option<String> = context.0.and_then(|v| {
835 if v.is_null() || v.is_undefined() {
836 None
837 } else {
838 v.as_string().and_then(|s| s.to_string().ok())
839 }
840 });
841
842 tracing::debug!(
843 "registerCommand: plugin='{}', name='{}', handler='{}'",
844 plugin_name,
845 name,
846 handler_name
847 );
848
849 let tracking_key = if name.starts_with('%') {
853 format!("{}:{}", plugin_name, name)
854 } else {
855 name.clone()
856 };
857 {
858 let names = self.registered_command_names.borrow();
859 if let Some(existing_plugin) = names.get(&tracking_key) {
860 if existing_plugin != &plugin_name {
861 let msg = format!(
862 "Command '{}' already registered by plugin '{}'",
863 name, existing_plugin
864 );
865 tracing::warn!("registerCommand collision: {}", msg);
866 return Err(
867 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
868 );
869 }
870 }
872 }
873
874 self.registered_command_names
876 .borrow_mut()
877 .insert(tracking_key, plugin_name.clone());
878
879 self.registered_actions.borrow_mut().insert(
881 handler_name.clone(),
882 PluginHandler {
883 plugin_name: self.plugin_name.clone(),
884 handler_name: handler_name.clone(),
885 },
886 );
887
888 let command = Command {
890 name: name.clone(),
891 description,
892 action_name: handler_name,
893 plugin_name,
894 custom_contexts: context_str.into_iter().collect(),
895 };
896
897 Ok(self
898 .command_sender
899 .send(PluginCommand::RegisterCommand { command })
900 .is_ok())
901 }
902
903 pub fn unregister_command(&self, name: String) -> bool {
905 let tracking_key = if name.starts_with('%') {
908 format!("{}:{}", self.plugin_name, name)
909 } else {
910 name.clone()
911 };
912 self.registered_command_names
913 .borrow_mut()
914 .remove(&tracking_key);
915 self.command_sender
916 .send(PluginCommand::UnregisterCommand { name })
917 .is_ok()
918 }
919
920 pub fn set_context(&self, name: String, active: bool) -> bool {
922 if active {
924 self.plugin_tracked_state
925 .borrow_mut()
926 .entry(self.plugin_name.clone())
927 .or_default()
928 .contexts_set
929 .push(name.clone());
930 }
931 self.command_sender
932 .send(PluginCommand::SetContext { name, active })
933 .is_ok()
934 }
935
936 pub fn execute_action(&self, action_name: String) -> bool {
938 self.command_sender
939 .send(PluginCommand::ExecuteAction { action_name })
940 .is_ok()
941 }
942
943 pub fn t<'js>(
948 &self,
949 _ctx: rquickjs::Ctx<'js>,
950 key: String,
951 args: rquickjs::function::Rest<Value<'js>>,
952 ) -> String {
953 let plugin_name = self.plugin_name.clone();
955 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
957 if let Some(obj) = first_arg.as_object() {
958 let mut map = HashMap::new();
959 for k in obj.keys::<String>().flatten() {
960 if let Ok(v) = obj.get::<_, String>(&k) {
961 map.insert(k, v);
962 }
963 }
964 map
965 } else {
966 HashMap::new()
967 }
968 } else {
969 HashMap::new()
970 };
971 let res = self.services.translate(&plugin_name, &key, &args_map);
972
973 tracing::info!(
974 "Translating: key={}, plugin={}, args={:?} => res='{}'",
975 key,
976 plugin_name,
977 args_map,
978 res
979 );
980 res
981 }
982
983 pub fn get_cursor_position(&self) -> u32 {
987 self.state_snapshot
988 .read()
989 .ok()
990 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
991 .unwrap_or(0)
992 }
993
994 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
996 if let Ok(s) = self.state_snapshot.read() {
997 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
998 if let Some(p) = &b.path {
999 return p.to_string_lossy().to_string();
1000 }
1001 }
1002 }
1003 String::new()
1004 }
1005
1006 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1008 if let Ok(s) = self.state_snapshot.read() {
1009 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1010 return b.length as u32;
1011 }
1012 }
1013 0
1014 }
1015
1016 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1018 if let Ok(s) = self.state_snapshot.read() {
1019 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1020 return b.modified;
1021 }
1022 }
1023 false
1024 }
1025
1026 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1029 self.command_sender
1030 .send(PluginCommand::SaveBufferToPath {
1031 buffer_id: BufferId(buffer_id as usize),
1032 path: std::path::PathBuf::from(path),
1033 })
1034 .is_ok()
1035 }
1036
1037 #[plugin_api(ts_return = "BufferInfo | null")]
1039 pub fn get_buffer_info<'js>(
1040 &self,
1041 ctx: rquickjs::Ctx<'js>,
1042 buffer_id: u32,
1043 ) -> rquickjs::Result<Value<'js>> {
1044 let info = if let Ok(s) = self.state_snapshot.read() {
1045 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1046 } else {
1047 None
1048 };
1049 rquickjs_serde::to_value(ctx, &info)
1050 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1051 }
1052
1053 #[plugin_api(ts_return = "CursorInfo | null")]
1055 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1056 let cursor = if let Ok(s) = self.state_snapshot.read() {
1057 s.primary_cursor.clone()
1058 } else {
1059 None
1060 };
1061 rquickjs_serde::to_value(ctx, &cursor)
1062 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1063 }
1064
1065 #[plugin_api(ts_return = "CursorInfo[]")]
1067 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1068 let cursors = if let Ok(s) = self.state_snapshot.read() {
1069 s.all_cursors.clone()
1070 } else {
1071 Vec::new()
1072 };
1073 rquickjs_serde::to_value(ctx, &cursors)
1074 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1075 }
1076
1077 #[plugin_api(ts_return = "number[]")]
1079 pub fn get_all_cursor_positions<'js>(
1080 &self,
1081 ctx: rquickjs::Ctx<'js>,
1082 ) -> rquickjs::Result<Value<'js>> {
1083 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1084 s.all_cursors.iter().map(|c| c.position as u32).collect()
1085 } else {
1086 Vec::new()
1087 };
1088 rquickjs_serde::to_value(ctx, &positions)
1089 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1090 }
1091
1092 #[plugin_api(ts_return = "ViewportInfo | null")]
1094 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1095 let viewport = if let Ok(s) = self.state_snapshot.read() {
1096 s.viewport.clone()
1097 } else {
1098 None
1099 };
1100 rquickjs_serde::to_value(ctx, &viewport)
1101 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1102 }
1103
1104 pub fn get_cursor_line(&self) -> u32 {
1106 0
1110 }
1111
1112 #[plugin_api(
1115 async_promise,
1116 js_name = "getLineStartPosition",
1117 ts_return = "number | null"
1118 )]
1119 #[qjs(rename = "_getLineStartPositionStart")]
1120 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1121 let id = {
1122 let mut id_ref = self.next_request_id.borrow_mut();
1123 let id = *id_ref;
1124 *id_ref += 1;
1125 self.callback_contexts
1127 .borrow_mut()
1128 .insert(id, self.plugin_name.clone());
1129 id
1130 };
1131 let _ = self
1133 .command_sender
1134 .send(PluginCommand::GetLineStartPosition {
1135 buffer_id: BufferId(0),
1136 line,
1137 request_id: id,
1138 });
1139 id
1140 }
1141
1142 #[plugin_api(
1146 async_promise,
1147 js_name = "getLineEndPosition",
1148 ts_return = "number | null"
1149 )]
1150 #[qjs(rename = "_getLineEndPositionStart")]
1151 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1152 let id = {
1153 let mut id_ref = self.next_request_id.borrow_mut();
1154 let id = *id_ref;
1155 *id_ref += 1;
1156 self.callback_contexts
1157 .borrow_mut()
1158 .insert(id, self.plugin_name.clone());
1159 id
1160 };
1161 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1163 buffer_id: BufferId(0),
1164 line,
1165 request_id: id,
1166 });
1167 id
1168 }
1169
1170 #[plugin_api(
1173 async_promise,
1174 js_name = "getBufferLineCount",
1175 ts_return = "number | null"
1176 )]
1177 #[qjs(rename = "_getBufferLineCountStart")]
1178 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1179 let id = {
1180 let mut id_ref = self.next_request_id.borrow_mut();
1181 let id = *id_ref;
1182 *id_ref += 1;
1183 self.callback_contexts
1184 .borrow_mut()
1185 .insert(id, self.plugin_name.clone());
1186 id
1187 };
1188 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1190 buffer_id: BufferId(0),
1191 request_id: id,
1192 });
1193 id
1194 }
1195
1196 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1199 self.command_sender
1200 .send(PluginCommand::ScrollToLineCenter {
1201 split_id: SplitId(split_id as usize),
1202 buffer_id: BufferId(buffer_id as usize),
1203 line: line as usize,
1204 })
1205 .is_ok()
1206 }
1207
1208 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1217 self.command_sender
1218 .send(PluginCommand::ScrollBufferToLine {
1219 buffer_id: BufferId(buffer_id as usize),
1220 line: line as usize,
1221 })
1222 .is_ok()
1223 }
1224
1225 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1227 let path_buf = std::path::PathBuf::from(&path);
1228 if let Ok(s) = self.state_snapshot.read() {
1229 for (id, info) in &s.buffers {
1230 if let Some(buf_path) = &info.path {
1231 if buf_path == &path_buf {
1232 return id.0 as u32;
1233 }
1234 }
1235 }
1236 }
1237 0
1238 }
1239
1240 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1242 pub fn get_buffer_saved_diff<'js>(
1243 &self,
1244 ctx: rquickjs::Ctx<'js>,
1245 buffer_id: u32,
1246 ) -> rquickjs::Result<Value<'js>> {
1247 let diff = if let Ok(s) = self.state_snapshot.read() {
1248 s.buffer_saved_diffs
1249 .get(&BufferId(buffer_id as usize))
1250 .cloned()
1251 } else {
1252 None
1253 };
1254 rquickjs_serde::to_value(ctx, &diff)
1255 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1256 }
1257
1258 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1262 self.command_sender
1263 .send(PluginCommand::InsertText {
1264 buffer_id: BufferId(buffer_id as usize),
1265 position: position as usize,
1266 text,
1267 })
1268 .is_ok()
1269 }
1270
1271 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1273 self.command_sender
1274 .send(PluginCommand::DeleteRange {
1275 buffer_id: BufferId(buffer_id as usize),
1276 range: (start as usize)..(end as usize),
1277 })
1278 .is_ok()
1279 }
1280
1281 pub fn insert_at_cursor(&self, text: String) -> bool {
1283 self.command_sender
1284 .send(PluginCommand::InsertAtCursor { text })
1285 .is_ok()
1286 }
1287
1288 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1292 self.command_sender
1293 .send(PluginCommand::OpenFileAtLocation {
1294 path: PathBuf::from(path),
1295 line: line.map(|l| l as usize),
1296 column: column.map(|c| c as usize),
1297 })
1298 .is_ok()
1299 }
1300
1301 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1303 self.command_sender
1304 .send(PluginCommand::OpenFileInSplit {
1305 split_id: split_id as usize,
1306 path: PathBuf::from(path),
1307 line: Some(line as usize),
1308 column: Some(column as usize),
1309 })
1310 .is_ok()
1311 }
1312
1313 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1315 self.command_sender
1316 .send(PluginCommand::ShowBuffer {
1317 buffer_id: BufferId(buffer_id as usize),
1318 })
1319 .is_ok()
1320 }
1321
1322 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1324 self.command_sender
1325 .send(PluginCommand::CloseBuffer {
1326 buffer_id: BufferId(buffer_id as usize),
1327 })
1328 .is_ok()
1329 }
1330
1331 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1335 if event_name == "lines_changed" {
1339 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1340 }
1341 self.event_handlers
1342 .borrow_mut()
1343 .entry(event_name)
1344 .or_default()
1345 .push(PluginHandler {
1346 plugin_name: self.plugin_name.clone(),
1347 handler_name,
1348 });
1349 }
1350
1351 pub fn off(&self, event_name: String, handler_name: String) {
1353 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1354 list.retain(|h| h.handler_name != handler_name);
1355 }
1356 }
1357
1358 pub fn get_env(&self, name: String) -> Option<String> {
1362 std::env::var(&name).ok()
1363 }
1364
1365 pub fn get_cwd(&self) -> String {
1367 self.state_snapshot
1368 .read()
1369 .map(|s| s.working_dir.to_string_lossy().to_string())
1370 .unwrap_or_else(|_| ".".to_string())
1371 }
1372
1373 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1378 let mut result_parts: Vec<String> = Vec::new();
1379 let mut has_leading_slash = false;
1380
1381 for part in &parts.0 {
1382 let normalized = part.replace('\\', "/");
1384
1385 let is_absolute = normalized.starts_with('/')
1387 || (normalized.len() >= 2
1388 && normalized
1389 .chars()
1390 .next()
1391 .map(|c| c.is_ascii_alphabetic())
1392 .unwrap_or(false)
1393 && normalized.chars().nth(1) == Some(':'));
1394
1395 if is_absolute {
1396 result_parts.clear();
1398 has_leading_slash = normalized.starts_with('/');
1399 }
1400
1401 for segment in normalized.split('/') {
1403 if !segment.is_empty() && segment != "." {
1404 if segment == ".." {
1405 result_parts.pop();
1406 } else {
1407 result_parts.push(segment.to_string());
1408 }
1409 }
1410 }
1411 }
1412
1413 let joined = result_parts.join("/");
1415
1416 if has_leading_slash && !joined.is_empty() {
1418 format!("/{}", joined)
1419 } else {
1420 joined
1421 }
1422 }
1423
1424 pub fn path_dirname(&self, path: String) -> String {
1426 Path::new(&path)
1427 .parent()
1428 .map(|p| p.to_string_lossy().to_string())
1429 .unwrap_or_default()
1430 }
1431
1432 pub fn path_basename(&self, path: String) -> String {
1434 Path::new(&path)
1435 .file_name()
1436 .map(|s| s.to_string_lossy().to_string())
1437 .unwrap_or_default()
1438 }
1439
1440 pub fn path_extname(&self, path: String) -> String {
1442 Path::new(&path)
1443 .extension()
1444 .map(|s| format!(".{}", s.to_string_lossy()))
1445 .unwrap_or_default()
1446 }
1447
1448 pub fn path_is_absolute(&self, path: String) -> bool {
1450 Path::new(&path).is_absolute()
1451 }
1452
1453 pub fn file_uri_to_path(&self, uri: String) -> String {
1457 fresh_core::file_uri::file_uri_to_path(&uri)
1458 .map(|p| p.to_string_lossy().to_string())
1459 .unwrap_or_default()
1460 }
1461
1462 pub fn path_to_file_uri(&self, path: String) -> String {
1466 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
1467 }
1468
1469 pub fn utf8_byte_length(&self, text: String) -> u32 {
1477 text.len() as u32
1478 }
1479
1480 pub fn file_exists(&self, path: String) -> bool {
1484 Path::new(&path).exists()
1485 }
1486
1487 pub fn read_file(&self, path: String) -> Option<String> {
1489 std::fs::read_to_string(&path).ok()
1490 }
1491
1492 pub fn write_file(&self, path: String, content: String) -> bool {
1494 let p = Path::new(&path);
1495 if let Some(parent) = p.parent() {
1496 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1497 return false;
1498 }
1499 }
1500 std::fs::write(p, content).is_ok()
1501 }
1502
1503 #[plugin_api(ts_return = "DirEntry[]")]
1505 pub fn read_dir<'js>(
1506 &self,
1507 ctx: rquickjs::Ctx<'js>,
1508 path: String,
1509 ) -> rquickjs::Result<Value<'js>> {
1510 use fresh_core::api::DirEntry;
1511
1512 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1513 Ok(entries) => entries
1514 .filter_map(|e| e.ok())
1515 .map(|entry| {
1516 let file_type = entry.file_type().ok();
1517 DirEntry {
1518 name: entry.file_name().to_string_lossy().to_string(),
1519 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1520 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1521 }
1522 })
1523 .collect(),
1524 Err(e) => {
1525 tracing::warn!("readDir failed for '{}': {}", path, e);
1526 Vec::new()
1527 }
1528 };
1529
1530 rquickjs_serde::to_value(ctx, &entries)
1531 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1532 }
1533
1534 pub fn create_dir(&self, path: String) -> bool {
1537 let p = Path::new(&path);
1538 if p.is_dir() {
1539 return true;
1540 }
1541 std::fs::create_dir_all(p).is_ok()
1542 }
1543
1544 pub fn remove_path(&self, path: String) -> bool {
1548 let target = match Path::new(&path).canonicalize() {
1549 Ok(p) => p,
1550 Err(_) => return false, };
1552
1553 let temp_dir = std::env::temp_dir()
1559 .canonicalize()
1560 .unwrap_or_else(|_| std::env::temp_dir());
1561 let config_dir = self
1562 .services
1563 .config_dir()
1564 .canonicalize()
1565 .unwrap_or_else(|_| self.services.config_dir());
1566
1567 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
1569 if !allowed {
1570 tracing::warn!(
1571 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
1572 target,
1573 temp_dir,
1574 config_dir
1575 );
1576 return false;
1577 }
1578
1579 if target == temp_dir || target == config_dir {
1581 tracing::warn!(
1582 "removePath refused: cannot remove root directory {:?}",
1583 target
1584 );
1585 return false;
1586 }
1587
1588 match trash::delete(&target) {
1589 Ok(()) => true,
1590 Err(e) => {
1591 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
1592 false
1593 }
1594 }
1595 }
1596
1597 pub fn rename_path(&self, from: String, to: String) -> bool {
1600 if std::fs::rename(&from, &to).is_ok() {
1602 return true;
1603 }
1604 let from_path = Path::new(&from);
1606 let copied = if from_path.is_dir() {
1607 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
1608 } else {
1609 std::fs::copy(&from, &to).is_ok()
1610 };
1611 if copied {
1612 return trash::delete(from_path).is_ok();
1613 }
1614 false
1615 }
1616
1617 pub fn copy_path(&self, from: String, to: String) -> bool {
1620 let from_path = Path::new(&from);
1621 let to_path = Path::new(&to);
1622 if from_path.is_dir() {
1623 copy_dir_recursive(from_path, to_path).is_ok()
1624 } else {
1625 if let Some(parent) = to_path.parent() {
1627 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1628 return false;
1629 }
1630 }
1631 std::fs::copy(from_path, to_path).is_ok()
1632 }
1633 }
1634
1635 pub fn get_temp_dir(&self) -> String {
1637 std::env::temp_dir().to_string_lossy().to_string()
1638 }
1639
1640 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1649 let config = self
1650 .state_snapshot
1651 .read()
1652 .map(|s| std::sync::Arc::clone(&s.config))
1653 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1654
1655 rquickjs_serde::to_value(ctx, &*config)
1656 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1657 }
1658
1659 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1661 let config = self
1662 .state_snapshot
1663 .read()
1664 .map(|s| std::sync::Arc::clone(&s.user_config))
1665 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1666
1667 rquickjs_serde::to_value(ctx, &*config)
1668 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1669 }
1670
1671 pub fn reload_config(&self) {
1673 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1674 }
1675
1676 pub fn reload_themes(&self) {
1679 let _ = self
1680 .command_sender
1681 .send(PluginCommand::ReloadThemes { apply_theme: None });
1682 }
1683
1684 pub fn reload_and_apply_theme(&self, theme_name: String) {
1686 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1687 apply_theme: Some(theme_name),
1688 });
1689 }
1690
1691 pub fn register_grammar<'js>(
1694 &self,
1695 ctx: rquickjs::Ctx<'js>,
1696 language: String,
1697 grammar_path: String,
1698 extensions: Vec<String>,
1699 ) -> rquickjs::Result<bool> {
1700 {
1702 let langs = self.registered_grammar_languages.borrow();
1703 if let Some(existing_plugin) = langs.get(&language) {
1704 if existing_plugin != &self.plugin_name {
1705 let msg = format!(
1706 "Grammar for language '{}' already registered by plugin '{}'",
1707 language, existing_plugin
1708 );
1709 tracing::warn!("registerGrammar collision: {}", msg);
1710 return Err(
1711 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1712 );
1713 }
1714 }
1715 }
1716 self.registered_grammar_languages
1717 .borrow_mut()
1718 .insert(language.clone(), self.plugin_name.clone());
1719
1720 Ok(self
1721 .command_sender
1722 .send(PluginCommand::RegisterGrammar {
1723 language,
1724 grammar_path,
1725 extensions,
1726 })
1727 .is_ok())
1728 }
1729
1730 pub fn register_language_config<'js>(
1732 &self,
1733 ctx: rquickjs::Ctx<'js>,
1734 language: String,
1735 config: LanguagePackConfig,
1736 ) -> rquickjs::Result<bool> {
1737 {
1739 let langs = self.registered_language_configs.borrow();
1740 if let Some(existing_plugin) = langs.get(&language) {
1741 if existing_plugin != &self.plugin_name {
1742 let msg = format!(
1743 "Language config for '{}' already registered by plugin '{}'",
1744 language, existing_plugin
1745 );
1746 tracing::warn!("registerLanguageConfig collision: {}", msg);
1747 return Err(
1748 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1749 );
1750 }
1751 }
1752 }
1753 self.registered_language_configs
1754 .borrow_mut()
1755 .insert(language.clone(), self.plugin_name.clone());
1756
1757 Ok(self
1758 .command_sender
1759 .send(PluginCommand::RegisterLanguageConfig { language, config })
1760 .is_ok())
1761 }
1762
1763 pub fn register_lsp_server<'js>(
1765 &self,
1766 ctx: rquickjs::Ctx<'js>,
1767 language: String,
1768 config: LspServerPackConfig,
1769 ) -> rquickjs::Result<bool> {
1770 {
1772 let langs = self.registered_lsp_servers.borrow();
1773 if let Some(existing_plugin) = langs.get(&language) {
1774 if existing_plugin != &self.plugin_name {
1775 let msg = format!(
1776 "LSP server for language '{}' already registered by plugin '{}'",
1777 language, existing_plugin
1778 );
1779 tracing::warn!("registerLspServer collision: {}", msg);
1780 return Err(
1781 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1782 );
1783 }
1784 }
1785 }
1786 self.registered_lsp_servers
1787 .borrow_mut()
1788 .insert(language.clone(), self.plugin_name.clone());
1789
1790 Ok(self
1791 .command_sender
1792 .send(PluginCommand::RegisterLspServer { language, config })
1793 .is_ok())
1794 }
1795
1796 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1800 #[qjs(rename = "_reloadGrammarsStart")]
1801 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1802 let id = {
1803 let mut id_ref = self.next_request_id.borrow_mut();
1804 let id = *id_ref;
1805 *id_ref += 1;
1806 self.callback_contexts
1807 .borrow_mut()
1808 .insert(id, self.plugin_name.clone());
1809 id
1810 };
1811 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1812 callback_id: fresh_core::api::JsCallbackId::new(id),
1813 });
1814 id
1815 }
1816
1817 pub fn get_plugin_dir(&self) -> String {
1820 self.services
1821 .plugins_dir()
1822 .join("packages")
1823 .join(&self.plugin_name)
1824 .to_string_lossy()
1825 .to_string()
1826 }
1827
1828 pub fn get_config_dir(&self) -> String {
1830 self.services.config_dir().to_string_lossy().to_string()
1831 }
1832
1833 pub fn get_data_dir(&self) -> String {
1837 self.services.data_dir().to_string_lossy().to_string()
1838 }
1839
1840 pub fn get_themes_dir(&self) -> String {
1842 self.services
1843 .config_dir()
1844 .join("themes")
1845 .to_string_lossy()
1846 .to_string()
1847 }
1848
1849 pub fn apply_theme(&self, theme_name: String) -> bool {
1851 self.command_sender
1852 .send(PluginCommand::ApplyTheme { theme_name })
1853 .is_ok()
1854 }
1855
1856 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1858 let schema = self.services.get_theme_schema();
1859 rquickjs_serde::to_value(ctx, &schema)
1860 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1861 }
1862
1863 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1865 let themes = self.services.get_builtin_themes();
1866 rquickjs_serde::to_value(ctx, &themes)
1867 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1868 }
1869
1870 #[qjs(rename = "_deleteThemeSync")]
1872 pub fn delete_theme_sync(&self, name: String) -> bool {
1873 let themes_dir = self.services.config_dir().join("themes");
1875 let theme_path = themes_dir.join(format!("{}.json", name));
1876
1877 if let Ok(canonical) = theme_path.canonicalize() {
1879 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1880 if canonical.starts_with(&themes_canonical) {
1881 return std::fs::remove_file(&canonical).is_ok();
1882 }
1883 }
1884 }
1885 false
1886 }
1887
1888 pub fn delete_theme(&self, name: String) -> bool {
1890 self.delete_theme_sync(name)
1891 }
1892
1893 pub fn get_theme_data<'js>(
1895 &self,
1896 ctx: rquickjs::Ctx<'js>,
1897 name: String,
1898 ) -> rquickjs::Result<Value<'js>> {
1899 match self.services.get_theme_data(&name) {
1900 Some(data) => rquickjs_serde::to_value(ctx, &data)
1901 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1902 None => Ok(Value::new_null(ctx)),
1903 }
1904 }
1905
1906 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
1908 self.services
1909 .save_theme_file(&name, &content)
1910 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
1911 }
1912
1913 pub fn theme_file_exists(&self, name: String) -> bool {
1915 self.services.theme_file_exists(&name)
1916 }
1917
1918 pub fn file_stat<'js>(
1922 &self,
1923 ctx: rquickjs::Ctx<'js>,
1924 path: String,
1925 ) -> rquickjs::Result<Value<'js>> {
1926 let metadata = std::fs::metadata(&path).ok();
1927 let stat = metadata.map(|m| {
1928 serde_json::json!({
1929 "isFile": m.is_file(),
1930 "isDir": m.is_dir(),
1931 "size": m.len(),
1932 "readonly": m.permissions().readonly(),
1933 })
1934 });
1935 rquickjs_serde::to_value(ctx, &stat)
1936 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1937 }
1938
1939 pub fn is_process_running(&self, _process_id: u64) -> bool {
1943 false
1946 }
1947
1948 pub fn kill_process(&self, process_id: u64) -> bool {
1950 self.command_sender
1951 .send(PluginCommand::KillBackgroundProcess { process_id })
1952 .is_ok()
1953 }
1954
1955 pub fn plugin_translate<'js>(
1959 &self,
1960 _ctx: rquickjs::Ctx<'js>,
1961 plugin_name: String,
1962 key: String,
1963 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1964 ) -> String {
1965 let args_map: HashMap<String, String> = args
1966 .0
1967 .map(|obj| {
1968 let mut map = HashMap::new();
1969 for (k, v) in obj.props::<String, String>().flatten() {
1970 map.insert(k, v);
1971 }
1972 map
1973 })
1974 .unwrap_or_default();
1975
1976 self.services.translate(&plugin_name, &key, &args_map)
1977 }
1978
1979 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1986 #[qjs(rename = "_createCompositeBufferStart")]
1987 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1988 let id = {
1989 let mut id_ref = self.next_request_id.borrow_mut();
1990 let id = *id_ref;
1991 *id_ref += 1;
1992 self.callback_contexts
1994 .borrow_mut()
1995 .insert(id, self.plugin_name.clone());
1996 id
1997 };
1998
1999 if let Ok(mut owners) = self.async_resource_owners.lock() {
2001 owners.insert(id, self.plugin_name.clone());
2002 }
2003 let _ = self
2004 .command_sender
2005 .send(PluginCommand::CreateCompositeBuffer {
2006 name: opts.name,
2007 mode: opts.mode,
2008 layout: opts.layout,
2009 sources: opts.sources,
2010 hunks: opts.hunks,
2011 initial_focus_hunk: opts.initial_focus_hunk,
2012 request_id: Some(id),
2013 });
2014
2015 id
2016 }
2017
2018 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
2022 self.command_sender
2023 .send(PluginCommand::UpdateCompositeAlignment {
2024 buffer_id: BufferId(buffer_id as usize),
2025 hunks,
2026 })
2027 .is_ok()
2028 }
2029
2030 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
2032 self.command_sender
2033 .send(PluginCommand::CloseCompositeBuffer {
2034 buffer_id: BufferId(buffer_id as usize),
2035 })
2036 .is_ok()
2037 }
2038
2039 pub fn flush_layout(&self) -> bool {
2043 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
2044 }
2045
2046 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
2048 self.command_sender
2049 .send(PluginCommand::CompositeNextHunk {
2050 buffer_id: BufferId(buffer_id as usize),
2051 })
2052 .is_ok()
2053 }
2054
2055 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
2057 self.command_sender
2058 .send(PluginCommand::CompositePrevHunk {
2059 buffer_id: BufferId(buffer_id as usize),
2060 })
2061 .is_ok()
2062 }
2063
2064 #[plugin_api(
2068 async_promise,
2069 js_name = "getHighlights",
2070 ts_return = "TsHighlightSpan[]"
2071 )]
2072 #[qjs(rename = "_getHighlightsStart")]
2073 pub fn get_highlights_start<'js>(
2074 &self,
2075 _ctx: rquickjs::Ctx<'js>,
2076 buffer_id: u32,
2077 start: u32,
2078 end: u32,
2079 ) -> rquickjs::Result<u64> {
2080 let id = {
2081 let mut id_ref = self.next_request_id.borrow_mut();
2082 let id = *id_ref;
2083 *id_ref += 1;
2084 self.callback_contexts
2086 .borrow_mut()
2087 .insert(id, self.plugin_name.clone());
2088 id
2089 };
2090
2091 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2092 buffer_id: BufferId(buffer_id as usize),
2093 range: (start as usize)..(end as usize),
2094 request_id: id,
2095 });
2096
2097 Ok(id)
2098 }
2099
2100 pub fn add_overlay<'js>(
2122 &self,
2123 _ctx: rquickjs::Ctx<'js>,
2124 buffer_id: u32,
2125 namespace: String,
2126 start: u32,
2127 end: u32,
2128 options: rquickjs::Object<'js>,
2129 ) -> rquickjs::Result<bool> {
2130 use fresh_core::api::OverlayColorSpec;
2131
2132 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2134 if let Ok(theme_key) = obj.get::<_, String>(key) {
2136 if !theme_key.is_empty() {
2137 return Some(OverlayColorSpec::ThemeKey(theme_key));
2138 }
2139 }
2140 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2142 if arr.len() >= 3 {
2143 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2144 }
2145 }
2146 None
2147 }
2148
2149 let fg = parse_color_spec("fg", &options);
2150 let bg = parse_color_spec("bg", &options);
2151 let underline: bool = options.get("underline").unwrap_or(false);
2152 let bold: bool = options.get("bold").unwrap_or(false);
2153 let italic: bool = options.get("italic").unwrap_or(false);
2154 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2155 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2156 let url: Option<String> = options.get("url").ok();
2157
2158 let options = OverlayOptions {
2159 fg,
2160 bg,
2161 underline,
2162 bold,
2163 italic,
2164 strikethrough,
2165 extend_to_line_end,
2166 url,
2167 };
2168
2169 self.plugin_tracked_state
2171 .borrow_mut()
2172 .entry(self.plugin_name.clone())
2173 .or_default()
2174 .overlay_namespaces
2175 .push((BufferId(buffer_id as usize), namespace.clone()));
2176
2177 let _ = self.command_sender.send(PluginCommand::AddOverlay {
2178 buffer_id: BufferId(buffer_id as usize),
2179 namespace: Some(OverlayNamespace::from_string(namespace)),
2180 range: (start as usize)..(end as usize),
2181 options,
2182 });
2183
2184 Ok(true)
2185 }
2186
2187 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2189 self.command_sender
2190 .send(PluginCommand::ClearNamespace {
2191 buffer_id: BufferId(buffer_id as usize),
2192 namespace: OverlayNamespace::from_string(namespace),
2193 })
2194 .is_ok()
2195 }
2196
2197 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2199 self.command_sender
2200 .send(PluginCommand::ClearAllOverlays {
2201 buffer_id: BufferId(buffer_id as usize),
2202 })
2203 .is_ok()
2204 }
2205
2206 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2208 self.command_sender
2209 .send(PluginCommand::ClearOverlaysInRange {
2210 buffer_id: BufferId(buffer_id as usize),
2211 start: start as usize,
2212 end: end as usize,
2213 })
2214 .is_ok()
2215 }
2216
2217 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2219 use fresh_core::overlay::OverlayHandle;
2220 self.command_sender
2221 .send(PluginCommand::RemoveOverlay {
2222 buffer_id: BufferId(buffer_id as usize),
2223 handle: OverlayHandle(handle),
2224 })
2225 .is_ok()
2226 }
2227
2228 pub fn add_conceal(
2232 &self,
2233 buffer_id: u32,
2234 namespace: String,
2235 start: u32,
2236 end: u32,
2237 replacement: Option<String>,
2238 ) -> bool {
2239 self.plugin_tracked_state
2241 .borrow_mut()
2242 .entry(self.plugin_name.clone())
2243 .or_default()
2244 .overlay_namespaces
2245 .push((BufferId(buffer_id as usize), namespace.clone()));
2246
2247 self.command_sender
2248 .send(PluginCommand::AddConceal {
2249 buffer_id: BufferId(buffer_id as usize),
2250 namespace: OverlayNamespace::from_string(namespace),
2251 start: start as usize,
2252 end: end as usize,
2253 replacement,
2254 })
2255 .is_ok()
2256 }
2257
2258 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2260 self.command_sender
2261 .send(PluginCommand::ClearConcealNamespace {
2262 buffer_id: BufferId(buffer_id as usize),
2263 namespace: OverlayNamespace::from_string(namespace),
2264 })
2265 .is_ok()
2266 }
2267
2268 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2270 self.command_sender
2271 .send(PluginCommand::ClearConcealsInRange {
2272 buffer_id: BufferId(buffer_id as usize),
2273 start: start as usize,
2274 end: end as usize,
2275 })
2276 .is_ok()
2277 }
2278
2279 pub fn add_fold(
2286 &self,
2287 buffer_id: u32,
2288 start: u32,
2289 end: u32,
2290 placeholder: rquickjs::function::Opt<String>,
2291 ) -> bool {
2292 self.command_sender
2293 .send(PluginCommand::AddFold {
2294 buffer_id: BufferId(buffer_id as usize),
2295 start: start as usize,
2296 end: end as usize,
2297 placeholder: placeholder.0,
2298 })
2299 .is_ok()
2300 }
2301
2302 pub fn clear_folds(&self, buffer_id: u32) -> bool {
2304 self.command_sender
2305 .send(PluginCommand::ClearFolds {
2306 buffer_id: BufferId(buffer_id as usize),
2307 })
2308 .is_ok()
2309 }
2310
2311 pub fn add_soft_break(
2315 &self,
2316 buffer_id: u32,
2317 namespace: String,
2318 position: u32,
2319 indent: u32,
2320 ) -> bool {
2321 self.plugin_tracked_state
2323 .borrow_mut()
2324 .entry(self.plugin_name.clone())
2325 .or_default()
2326 .overlay_namespaces
2327 .push((BufferId(buffer_id as usize), namespace.clone()));
2328
2329 self.command_sender
2330 .send(PluginCommand::AddSoftBreak {
2331 buffer_id: BufferId(buffer_id as usize),
2332 namespace: OverlayNamespace::from_string(namespace),
2333 position: position as usize,
2334 indent: indent as u16,
2335 })
2336 .is_ok()
2337 }
2338
2339 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2341 self.command_sender
2342 .send(PluginCommand::ClearSoftBreakNamespace {
2343 buffer_id: BufferId(buffer_id as usize),
2344 namespace: OverlayNamespace::from_string(namespace),
2345 })
2346 .is_ok()
2347 }
2348
2349 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2351 self.command_sender
2352 .send(PluginCommand::ClearSoftBreaksInRange {
2353 buffer_id: BufferId(buffer_id as usize),
2354 start: start as usize,
2355 end: end as usize,
2356 })
2357 .is_ok()
2358 }
2359
2360 #[allow(clippy::too_many_arguments)]
2370 pub fn submit_view_transform<'js>(
2371 &self,
2372 _ctx: rquickjs::Ctx<'js>,
2373 buffer_id: u32,
2374 split_id: Option<u32>,
2375 start: u32,
2376 end: u32,
2377 tokens: Vec<rquickjs::Object<'js>>,
2378 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2379 ) -> rquickjs::Result<bool> {
2380 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2381
2382 let tokens: Vec<ViewTokenWire> = tokens
2383 .into_iter()
2384 .enumerate()
2385 .map(|(idx, obj)| {
2386 parse_view_token(&obj, idx)
2388 })
2389 .collect::<rquickjs::Result<Vec<_>>>()?;
2390
2391 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2393 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2394 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2395 Some(LayoutHints {
2396 compose_width,
2397 column_guides,
2398 })
2399 } else {
2400 None
2401 };
2402
2403 let payload = ViewTransformPayload {
2404 range: (start as usize)..(end as usize),
2405 tokens,
2406 layout_hints: parsed_layout_hints,
2407 };
2408
2409 Ok(self
2410 .command_sender
2411 .send(PluginCommand::SubmitViewTransform {
2412 buffer_id: BufferId(buffer_id as usize),
2413 split_id: split_id.map(|id| SplitId(id as usize)),
2414 payload,
2415 })
2416 .is_ok())
2417 }
2418
2419 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2421 self.command_sender
2422 .send(PluginCommand::ClearViewTransform {
2423 buffer_id: BufferId(buffer_id as usize),
2424 split_id: split_id.map(|id| SplitId(id as usize)),
2425 })
2426 .is_ok()
2427 }
2428
2429 pub fn set_layout_hints<'js>(
2432 &self,
2433 buffer_id: u32,
2434 split_id: Option<u32>,
2435 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2436 ) -> rquickjs::Result<bool> {
2437 use fresh_core::api::LayoutHints;
2438
2439 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2440 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2441 let parsed_hints = LayoutHints {
2442 compose_width,
2443 column_guides,
2444 };
2445
2446 Ok(self
2447 .command_sender
2448 .send(PluginCommand::SetLayoutHints {
2449 buffer_id: BufferId(buffer_id as usize),
2450 split_id: split_id.map(|id| SplitId(id as usize)),
2451 range: 0..0,
2452 hints: parsed_hints,
2453 })
2454 .is_ok())
2455 }
2456
2457 pub fn set_file_explorer_decorations<'js>(
2461 &self,
2462 _ctx: rquickjs::Ctx<'js>,
2463 namespace: String,
2464 decorations: Vec<rquickjs::Object<'js>>,
2465 ) -> rquickjs::Result<bool> {
2466 use fresh_core::file_explorer::FileExplorerDecoration;
2467
2468 let decorations: Vec<FileExplorerDecoration> = decorations
2469 .into_iter()
2470 .map(|obj| {
2471 let path: String = obj.get("path")?;
2472 let symbol: String = obj.get("symbol")?;
2473 let priority: i32 = obj.get("priority").unwrap_or(0);
2474
2475 let color_val: rquickjs::Value = obj.get("color")?;
2477 let color = if color_val.is_string() {
2478 let key: String = color_val.get()?;
2479 fresh_core::api::OverlayColorSpec::ThemeKey(key)
2480 } else if color_val.is_array() {
2481 let arr: Vec<u8> = color_val.get()?;
2482 if arr.len() < 3 {
2483 return Err(rquickjs::Error::FromJs {
2484 from: "array",
2485 to: "color",
2486 message: Some(format!(
2487 "color array must have at least 3 elements, got {}",
2488 arr.len()
2489 )),
2490 });
2491 }
2492 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
2493 } else {
2494 return Err(rquickjs::Error::FromJs {
2495 from: "value",
2496 to: "color",
2497 message: Some("color must be an RGB array or theme key string".to_string()),
2498 });
2499 };
2500
2501 Ok(FileExplorerDecoration {
2502 path: std::path::PathBuf::from(path),
2503 symbol,
2504 color,
2505 priority,
2506 })
2507 })
2508 .collect::<rquickjs::Result<Vec<_>>>()?;
2509
2510 self.plugin_tracked_state
2512 .borrow_mut()
2513 .entry(self.plugin_name.clone())
2514 .or_default()
2515 .file_explorer_namespaces
2516 .push(namespace.clone());
2517
2518 Ok(self
2519 .command_sender
2520 .send(PluginCommand::SetFileExplorerDecorations {
2521 namespace,
2522 decorations,
2523 })
2524 .is_ok())
2525 }
2526
2527 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2529 self.command_sender
2530 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2531 .is_ok()
2532 }
2533
2534 #[allow(clippy::too_many_arguments)]
2538 pub fn add_virtual_text(
2539 &self,
2540 buffer_id: u32,
2541 virtual_text_id: String,
2542 position: u32,
2543 text: String,
2544 r: u8,
2545 g: u8,
2546 b: u8,
2547 before: bool,
2548 use_bg: bool,
2549 ) -> bool {
2550 self.plugin_tracked_state
2552 .borrow_mut()
2553 .entry(self.plugin_name.clone())
2554 .or_default()
2555 .virtual_text_ids
2556 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2557
2558 self.command_sender
2559 .send(PluginCommand::AddVirtualText {
2560 buffer_id: BufferId(buffer_id as usize),
2561 virtual_text_id,
2562 position: position as usize,
2563 text,
2564 color: (r, g, b),
2565 use_bg,
2566 before,
2567 })
2568 .is_ok()
2569 }
2570
2571 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2573 self.command_sender
2574 .send(PluginCommand::RemoveVirtualText {
2575 buffer_id: BufferId(buffer_id as usize),
2576 virtual_text_id,
2577 })
2578 .is_ok()
2579 }
2580
2581 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2583 self.command_sender
2584 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2585 buffer_id: BufferId(buffer_id as usize),
2586 prefix,
2587 })
2588 .is_ok()
2589 }
2590
2591 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2593 self.command_sender
2594 .send(PluginCommand::ClearVirtualTexts {
2595 buffer_id: BufferId(buffer_id as usize),
2596 })
2597 .is_ok()
2598 }
2599
2600 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2602 self.command_sender
2603 .send(PluginCommand::ClearVirtualTextNamespace {
2604 buffer_id: BufferId(buffer_id as usize),
2605 namespace,
2606 })
2607 .is_ok()
2608 }
2609
2610 #[allow(clippy::too_many_arguments)]
2618 pub fn add_virtual_line<'js>(
2619 &self,
2620 _ctx: rquickjs::Ctx<'js>,
2621 buffer_id: u32,
2622 position: u32,
2623 text: String,
2624 options: rquickjs::Object<'js>,
2625 above: bool,
2626 namespace: String,
2627 priority: i32,
2628 ) -> rquickjs::Result<bool> {
2629 use fresh_core::api::OverlayColorSpec;
2630
2631 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2634 if let Ok(theme_key) = obj.get::<_, String>(key) {
2635 if !theme_key.is_empty() {
2636 return Some(OverlayColorSpec::ThemeKey(theme_key));
2637 }
2638 }
2639 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2640 if arr.len() >= 3 {
2641 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2642 }
2643 }
2644 None
2645 }
2646
2647 let fg_color = parse_color_spec("fg", &options);
2648 let bg_color = parse_color_spec("bg", &options);
2649
2650 self.plugin_tracked_state
2652 .borrow_mut()
2653 .entry(self.plugin_name.clone())
2654 .or_default()
2655 .virtual_line_namespaces
2656 .push((BufferId(buffer_id as usize), namespace.clone()));
2657
2658 Ok(self
2659 .command_sender
2660 .send(PluginCommand::AddVirtualLine {
2661 buffer_id: BufferId(buffer_id as usize),
2662 position: position as usize,
2663 text,
2664 fg_color,
2665 bg_color,
2666 above,
2667 namespace,
2668 priority,
2669 })
2670 .is_ok())
2671 }
2672
2673 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2678 #[qjs(rename = "_promptStart")]
2679 pub fn prompt_start(
2680 &self,
2681 _ctx: rquickjs::Ctx<'_>,
2682 label: String,
2683 initial_value: String,
2684 ) -> u64 {
2685 let id = {
2686 let mut id_ref = self.next_request_id.borrow_mut();
2687 let id = *id_ref;
2688 *id_ref += 1;
2689 self.callback_contexts
2691 .borrow_mut()
2692 .insert(id, self.plugin_name.clone());
2693 id
2694 };
2695
2696 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2697 label,
2698 initial_value,
2699 callback_id: JsCallbackId::new(id),
2700 });
2701
2702 id
2703 }
2704
2705 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2707 self.command_sender
2708 .send(PluginCommand::StartPrompt { label, prompt_type })
2709 .is_ok()
2710 }
2711
2712 pub fn start_prompt_with_initial(
2714 &self,
2715 label: String,
2716 prompt_type: String,
2717 initial_value: String,
2718 ) -> bool {
2719 self.command_sender
2720 .send(PluginCommand::StartPromptWithInitial {
2721 label,
2722 prompt_type,
2723 initial_value,
2724 })
2725 .is_ok()
2726 }
2727
2728 pub fn set_prompt_suggestions(
2732 &self,
2733 suggestions: Vec<fresh_core::command::Suggestion>,
2734 ) -> bool {
2735 self.command_sender
2736 .send(PluginCommand::SetPromptSuggestions { suggestions })
2737 .is_ok()
2738 }
2739
2740 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2741 self.command_sender
2742 .send(PluginCommand::SetPromptInputSync { sync })
2743 .is_ok()
2744 }
2745
2746 pub fn define_mode(
2750 &self,
2751 name: String,
2752 bindings_arr: Vec<Vec<String>>,
2753 read_only: rquickjs::function::Opt<bool>,
2754 allow_text_input: rquickjs::function::Opt<bool>,
2755 inherit_normal_bindings: rquickjs::function::Opt<bool>,
2756 ) -> bool {
2757 let bindings: Vec<(String, String)> = bindings_arr
2758 .into_iter()
2759 .filter_map(|arr| {
2760 if arr.len() >= 2 {
2761 Some((arr[0].clone(), arr[1].clone()))
2762 } else {
2763 None
2764 }
2765 })
2766 .collect();
2767
2768 {
2771 let mut registered = self.registered_actions.borrow_mut();
2772 for (_, cmd_name) in &bindings {
2773 registered.insert(
2774 cmd_name.clone(),
2775 PluginHandler {
2776 plugin_name: self.plugin_name.clone(),
2777 handler_name: cmd_name.clone(),
2778 },
2779 );
2780 }
2781 }
2782
2783 let allow_text = allow_text_input.0.unwrap_or(false);
2786 if allow_text {
2787 let mut registered = self.registered_actions.borrow_mut();
2788 registered.insert(
2789 "mode_text_input".to_string(),
2790 PluginHandler {
2791 plugin_name: self.plugin_name.clone(),
2792 handler_name: "mode_text_input".to_string(),
2793 },
2794 );
2795 }
2796
2797 self.command_sender
2798 .send(PluginCommand::DefineMode {
2799 name,
2800 bindings,
2801 read_only: read_only.0.unwrap_or(false),
2802 allow_text_input: allow_text,
2803 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
2804 plugin_name: Some(self.plugin_name.clone()),
2805 })
2806 .is_ok()
2807 }
2808
2809 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2811 self.command_sender
2812 .send(PluginCommand::SetEditorMode { mode })
2813 .is_ok()
2814 }
2815
2816 pub fn get_editor_mode(&self) -> Option<String> {
2818 self.state_snapshot
2819 .read()
2820 .ok()
2821 .and_then(|s| s.editor_mode.clone())
2822 }
2823
2824 pub fn close_split(&self, split_id: u32) -> bool {
2828 self.command_sender
2829 .send(PluginCommand::CloseSplit {
2830 split_id: SplitId(split_id as usize),
2831 })
2832 .is_ok()
2833 }
2834
2835 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2837 self.command_sender
2838 .send(PluginCommand::SetSplitBuffer {
2839 split_id: SplitId(split_id as usize),
2840 buffer_id: BufferId(buffer_id as usize),
2841 })
2842 .is_ok()
2843 }
2844
2845 pub fn focus_split(&self, split_id: u32) -> bool {
2847 self.command_sender
2848 .send(PluginCommand::FocusSplit {
2849 split_id: SplitId(split_id as usize),
2850 })
2851 .is_ok()
2852 }
2853
2854 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2856 self.command_sender
2857 .send(PluginCommand::SetSplitScroll {
2858 split_id: SplitId(split_id as usize),
2859 top_byte: top_byte as usize,
2860 })
2861 .is_ok()
2862 }
2863
2864 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2866 self.command_sender
2867 .send(PluginCommand::SetSplitRatio {
2868 split_id: SplitId(split_id as usize),
2869 ratio,
2870 })
2871 .is_ok()
2872 }
2873
2874 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2876 self.command_sender
2877 .send(PluginCommand::SetSplitLabel {
2878 split_id: SplitId(split_id as usize),
2879 label,
2880 })
2881 .is_ok()
2882 }
2883
2884 pub fn clear_split_label(&self, split_id: u32) -> bool {
2886 self.command_sender
2887 .send(PluginCommand::ClearSplitLabel {
2888 split_id: SplitId(split_id as usize),
2889 })
2890 .is_ok()
2891 }
2892
2893 #[plugin_api(
2895 async_promise,
2896 js_name = "getSplitByLabel",
2897 ts_return = "number | null"
2898 )]
2899 #[qjs(rename = "_getSplitByLabelStart")]
2900 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2901 let id = {
2902 let mut id_ref = self.next_request_id.borrow_mut();
2903 let id = *id_ref;
2904 *id_ref += 1;
2905 self.callback_contexts
2906 .borrow_mut()
2907 .insert(id, self.plugin_name.clone());
2908 id
2909 };
2910 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2911 label,
2912 request_id: id,
2913 });
2914 id
2915 }
2916
2917 pub fn distribute_splits_evenly(&self) -> bool {
2919 self.command_sender
2921 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2922 .is_ok()
2923 }
2924
2925 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2927 self.command_sender
2928 .send(PluginCommand::SetBufferCursor {
2929 buffer_id: BufferId(buffer_id as usize),
2930 position: position as usize,
2931 })
2932 .is_ok()
2933 }
2934
2935 #[qjs(rename = "setBufferShowCursors")]
2942 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
2943 self.command_sender
2944 .send(PluginCommand::SetBufferShowCursors {
2945 buffer_id: BufferId(buffer_id as usize),
2946 show,
2947 })
2948 .is_ok()
2949 }
2950
2951 #[allow(clippy::too_many_arguments)]
2955 pub fn set_line_indicator(
2956 &self,
2957 buffer_id: u32,
2958 line: u32,
2959 namespace: String,
2960 symbol: String,
2961 r: u8,
2962 g: u8,
2963 b: u8,
2964 priority: i32,
2965 ) -> bool {
2966 self.plugin_tracked_state
2968 .borrow_mut()
2969 .entry(self.plugin_name.clone())
2970 .or_default()
2971 .line_indicator_namespaces
2972 .push((BufferId(buffer_id as usize), namespace.clone()));
2973
2974 self.command_sender
2975 .send(PluginCommand::SetLineIndicator {
2976 buffer_id: BufferId(buffer_id as usize),
2977 line: line as usize,
2978 namespace,
2979 symbol,
2980 color: (r, g, b),
2981 priority,
2982 })
2983 .is_ok()
2984 }
2985
2986 #[allow(clippy::too_many_arguments)]
2988 pub fn set_line_indicators(
2989 &self,
2990 buffer_id: u32,
2991 lines: Vec<u32>,
2992 namespace: String,
2993 symbol: String,
2994 r: u8,
2995 g: u8,
2996 b: u8,
2997 priority: i32,
2998 ) -> bool {
2999 self.plugin_tracked_state
3001 .borrow_mut()
3002 .entry(self.plugin_name.clone())
3003 .or_default()
3004 .line_indicator_namespaces
3005 .push((BufferId(buffer_id as usize), namespace.clone()));
3006
3007 self.command_sender
3008 .send(PluginCommand::SetLineIndicators {
3009 buffer_id: BufferId(buffer_id as usize),
3010 lines: lines.into_iter().map(|l| l as usize).collect(),
3011 namespace,
3012 symbol,
3013 color: (r, g, b),
3014 priority,
3015 })
3016 .is_ok()
3017 }
3018
3019 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
3021 self.command_sender
3022 .send(PluginCommand::ClearLineIndicators {
3023 buffer_id: BufferId(buffer_id as usize),
3024 namespace,
3025 })
3026 .is_ok()
3027 }
3028
3029 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
3031 self.command_sender
3032 .send(PluginCommand::SetLineNumbers {
3033 buffer_id: BufferId(buffer_id as usize),
3034 enabled,
3035 })
3036 .is_ok()
3037 }
3038
3039 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
3041 self.command_sender
3042 .send(PluginCommand::SetViewMode {
3043 buffer_id: BufferId(buffer_id as usize),
3044 mode,
3045 })
3046 .is_ok()
3047 }
3048
3049 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
3051 self.command_sender
3052 .send(PluginCommand::SetLineWrap {
3053 buffer_id: BufferId(buffer_id as usize),
3054 split_id: split_id.map(|s| SplitId(s as usize)),
3055 enabled,
3056 })
3057 .is_ok()
3058 }
3059
3060 pub fn set_view_state<'js>(
3064 &self,
3065 ctx: rquickjs::Ctx<'js>,
3066 buffer_id: u32,
3067 key: String,
3068 value: Value<'js>,
3069 ) -> bool {
3070 let bid = BufferId(buffer_id as usize);
3071
3072 let json_value = if value.is_undefined() || value.is_null() {
3074 None
3075 } else {
3076 Some(js_to_json(&ctx, value))
3077 };
3078
3079 if let Ok(mut snapshot) = self.state_snapshot.write() {
3081 if let Some(ref json_val) = json_value {
3082 snapshot
3083 .plugin_view_states
3084 .entry(bid)
3085 .or_default()
3086 .insert(key.clone(), json_val.clone());
3087 } else {
3088 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
3090 map.remove(&key);
3091 if map.is_empty() {
3092 snapshot.plugin_view_states.remove(&bid);
3093 }
3094 }
3095 }
3096 }
3097
3098 self.command_sender
3100 .send(PluginCommand::SetViewState {
3101 buffer_id: bid,
3102 key,
3103 value: json_value,
3104 })
3105 .is_ok()
3106 }
3107
3108 pub fn get_view_state<'js>(
3110 &self,
3111 ctx: rquickjs::Ctx<'js>,
3112 buffer_id: u32,
3113 key: String,
3114 ) -> rquickjs::Result<Value<'js>> {
3115 let bid = BufferId(buffer_id as usize);
3116 if let Ok(snapshot) = self.state_snapshot.read() {
3117 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
3118 if let Some(json_val) = map.get(&key) {
3119 return json_to_js_value(&ctx, json_val);
3120 }
3121 }
3122 }
3123 Ok(Value::new_undefined(ctx.clone()))
3124 }
3125
3126 pub fn set_global_state<'js>(
3132 &self,
3133 ctx: rquickjs::Ctx<'js>,
3134 key: String,
3135 value: Value<'js>,
3136 ) -> bool {
3137 let json_value = if value.is_undefined() || value.is_null() {
3139 None
3140 } else {
3141 Some(js_to_json(&ctx, value))
3142 };
3143
3144 if let Ok(mut snapshot) = self.state_snapshot.write() {
3146 if let Some(ref json_val) = json_value {
3147 snapshot
3148 .plugin_global_states
3149 .entry(self.plugin_name.clone())
3150 .or_default()
3151 .insert(key.clone(), json_val.clone());
3152 } else {
3153 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3155 map.remove(&key);
3156 if map.is_empty() {
3157 snapshot.plugin_global_states.remove(&self.plugin_name);
3158 }
3159 }
3160 }
3161 }
3162
3163 self.command_sender
3165 .send(PluginCommand::SetGlobalState {
3166 plugin_name: self.plugin_name.clone(),
3167 key,
3168 value: json_value,
3169 })
3170 .is_ok()
3171 }
3172
3173 pub fn get_global_state<'js>(
3177 &self,
3178 ctx: rquickjs::Ctx<'js>,
3179 key: String,
3180 ) -> rquickjs::Result<Value<'js>> {
3181 if let Ok(snapshot) = self.state_snapshot.read() {
3182 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3183 if let Some(json_val) = map.get(&key) {
3184 return json_to_js_value(&ctx, json_val);
3185 }
3186 }
3187 }
3188 Ok(Value::new_undefined(ctx.clone()))
3189 }
3190
3191 pub fn create_scroll_sync_group(
3195 &self,
3196 group_id: u32,
3197 left_split: u32,
3198 right_split: u32,
3199 ) -> bool {
3200 self.plugin_tracked_state
3202 .borrow_mut()
3203 .entry(self.plugin_name.clone())
3204 .or_default()
3205 .scroll_sync_group_ids
3206 .push(group_id);
3207 self.command_sender
3208 .send(PluginCommand::CreateScrollSyncGroup {
3209 group_id,
3210 left_split: SplitId(left_split as usize),
3211 right_split: SplitId(right_split as usize),
3212 })
3213 .is_ok()
3214 }
3215
3216 pub fn set_scroll_sync_anchors<'js>(
3218 &self,
3219 _ctx: rquickjs::Ctx<'js>,
3220 group_id: u32,
3221 anchors: Vec<Vec<u32>>,
3222 ) -> bool {
3223 let anchors: Vec<(usize, usize)> = anchors
3224 .into_iter()
3225 .filter_map(|pair| {
3226 if pair.len() >= 2 {
3227 Some((pair[0] as usize, pair[1] as usize))
3228 } else {
3229 None
3230 }
3231 })
3232 .collect();
3233 self.command_sender
3234 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3235 .is_ok()
3236 }
3237
3238 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3240 self.command_sender
3241 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3242 .is_ok()
3243 }
3244
3245 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3251 self.command_sender
3252 .send(PluginCommand::ExecuteActions { actions })
3253 .is_ok()
3254 }
3255
3256 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3260 self.command_sender
3261 .send(PluginCommand::ShowActionPopup {
3262 popup_id: opts.id,
3263 title: opts.title,
3264 message: opts.message,
3265 actions: opts.actions,
3266 })
3267 .is_ok()
3268 }
3269
3270 pub fn disable_lsp_for_language(&self, language: String) -> bool {
3272 self.command_sender
3273 .send(PluginCommand::DisableLspForLanguage { language })
3274 .is_ok()
3275 }
3276
3277 pub fn restart_lsp_for_language(&self, language: String) -> bool {
3279 self.command_sender
3280 .send(PluginCommand::RestartLspForLanguage { language })
3281 .is_ok()
3282 }
3283
3284 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3287 self.command_sender
3288 .send(PluginCommand::SetLspRootUri { language, uri })
3289 .is_ok()
3290 }
3291
3292 #[plugin_api(ts_return = "JsDiagnostic[]")]
3294 pub fn get_all_diagnostics<'js>(
3295 &self,
3296 ctx: rquickjs::Ctx<'js>,
3297 ) -> rquickjs::Result<Value<'js>> {
3298 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3299
3300 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3301 let mut result: Vec<JsDiagnostic> = Vec::new();
3303 for (uri, diags) in s.diagnostics.iter() {
3304 for diag in diags {
3305 result.push(JsDiagnostic {
3306 uri: uri.clone(),
3307 message: diag.message.clone(),
3308 severity: diag.severity.map(|s| match s {
3309 lsp_types::DiagnosticSeverity::ERROR => 1,
3310 lsp_types::DiagnosticSeverity::WARNING => 2,
3311 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3312 lsp_types::DiagnosticSeverity::HINT => 4,
3313 _ => 0,
3314 }),
3315 range: JsRange {
3316 start: JsPosition {
3317 line: diag.range.start.line,
3318 character: diag.range.start.character,
3319 },
3320 end: JsPosition {
3321 line: diag.range.end.line,
3322 character: diag.range.end.character,
3323 },
3324 },
3325 source: diag.source.clone(),
3326 });
3327 }
3328 }
3329 result
3330 } else {
3331 Vec::new()
3332 };
3333 rquickjs_serde::to_value(ctx, &diagnostics)
3334 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3335 }
3336
3337 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3339 self.event_handlers
3340 .borrow()
3341 .get(&event_name)
3342 .cloned()
3343 .unwrap_or_default()
3344 .into_iter()
3345 .map(|h| h.handler_name)
3346 .collect()
3347 }
3348
3349 #[plugin_api(
3353 async_promise,
3354 js_name = "createVirtualBuffer",
3355 ts_return = "VirtualBufferResult"
3356 )]
3357 #[qjs(rename = "_createVirtualBufferStart")]
3358 pub fn create_virtual_buffer_start(
3359 &self,
3360 _ctx: rquickjs::Ctx<'_>,
3361 opts: fresh_core::api::CreateVirtualBufferOptions,
3362 ) -> rquickjs::Result<u64> {
3363 let id = {
3364 let mut id_ref = self.next_request_id.borrow_mut();
3365 let id = *id_ref;
3366 *id_ref += 1;
3367 self.callback_contexts
3369 .borrow_mut()
3370 .insert(id, self.plugin_name.clone());
3371 id
3372 };
3373
3374 let entries: Vec<TextPropertyEntry> = opts
3376 .entries
3377 .unwrap_or_default()
3378 .into_iter()
3379 .map(|e| TextPropertyEntry {
3380 text: e.text,
3381 properties: e.properties.unwrap_or_default(),
3382 style: e.style,
3383 inline_overlays: e.inline_overlays.unwrap_or_default(),
3384 })
3385 .collect();
3386
3387 tracing::debug!(
3388 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3389 id
3390 );
3391 if let Ok(mut owners) = self.async_resource_owners.lock() {
3393 owners.insert(id, self.plugin_name.clone());
3394 }
3395 let _ = self
3396 .command_sender
3397 .send(PluginCommand::CreateVirtualBufferWithContent {
3398 name: opts.name,
3399 mode: opts.mode.unwrap_or_default(),
3400 read_only: opts.read_only.unwrap_or(false),
3401 entries,
3402 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3403 show_cursors: opts.show_cursors.unwrap_or(true),
3404 editing_disabled: opts.editing_disabled.unwrap_or(false),
3405 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3406 request_id: Some(id),
3407 });
3408 Ok(id)
3409 }
3410
3411 #[plugin_api(
3413 async_promise,
3414 js_name = "createVirtualBufferInSplit",
3415 ts_return = "VirtualBufferResult"
3416 )]
3417 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3418 pub fn create_virtual_buffer_in_split_start(
3419 &self,
3420 _ctx: rquickjs::Ctx<'_>,
3421 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3422 ) -> rquickjs::Result<u64> {
3423 let id = {
3424 let mut id_ref = self.next_request_id.borrow_mut();
3425 let id = *id_ref;
3426 *id_ref += 1;
3427 self.callback_contexts
3429 .borrow_mut()
3430 .insert(id, self.plugin_name.clone());
3431 id
3432 };
3433
3434 let entries: Vec<TextPropertyEntry> = opts
3436 .entries
3437 .unwrap_or_default()
3438 .into_iter()
3439 .map(|e| TextPropertyEntry {
3440 text: e.text,
3441 properties: e.properties.unwrap_or_default(),
3442 style: e.style,
3443 inline_overlays: e.inline_overlays.unwrap_or_default(),
3444 })
3445 .collect();
3446
3447 if let Ok(mut owners) = self.async_resource_owners.lock() {
3449 owners.insert(id, self.plugin_name.clone());
3450 }
3451 let _ = self
3452 .command_sender
3453 .send(PluginCommand::CreateVirtualBufferInSplit {
3454 name: opts.name,
3455 mode: opts.mode.unwrap_or_default(),
3456 read_only: opts.read_only.unwrap_or(false),
3457 entries,
3458 ratio: opts.ratio.unwrap_or(0.5),
3459 direction: opts.direction,
3460 panel_id: opts.panel_id,
3461 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3462 show_cursors: opts.show_cursors.unwrap_or(true),
3463 editing_disabled: opts.editing_disabled.unwrap_or(false),
3464 line_wrap: opts.line_wrap,
3465 before: opts.before.unwrap_or(false),
3466 request_id: Some(id),
3467 });
3468 Ok(id)
3469 }
3470
3471 #[plugin_api(
3473 async_promise,
3474 js_name = "createVirtualBufferInExistingSplit",
3475 ts_return = "VirtualBufferResult"
3476 )]
3477 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3478 pub fn create_virtual_buffer_in_existing_split_start(
3479 &self,
3480 _ctx: rquickjs::Ctx<'_>,
3481 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3482 ) -> rquickjs::Result<u64> {
3483 let id = {
3484 let mut id_ref = self.next_request_id.borrow_mut();
3485 let id = *id_ref;
3486 *id_ref += 1;
3487 self.callback_contexts
3489 .borrow_mut()
3490 .insert(id, self.plugin_name.clone());
3491 id
3492 };
3493
3494 let entries: Vec<TextPropertyEntry> = opts
3496 .entries
3497 .unwrap_or_default()
3498 .into_iter()
3499 .map(|e| TextPropertyEntry {
3500 text: e.text,
3501 properties: e.properties.unwrap_or_default(),
3502 style: e.style,
3503 inline_overlays: e.inline_overlays.unwrap_or_default(),
3504 })
3505 .collect();
3506
3507 if let Ok(mut owners) = self.async_resource_owners.lock() {
3509 owners.insert(id, self.plugin_name.clone());
3510 }
3511 let _ = self
3512 .command_sender
3513 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3514 name: opts.name,
3515 mode: opts.mode.unwrap_or_default(),
3516 read_only: opts.read_only.unwrap_or(false),
3517 entries,
3518 split_id: SplitId(opts.split_id),
3519 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3520 show_cursors: opts.show_cursors.unwrap_or(true),
3521 editing_disabled: opts.editing_disabled.unwrap_or(false),
3522 line_wrap: opts.line_wrap,
3523 request_id: Some(id),
3524 });
3525 Ok(id)
3526 }
3527
3528 #[qjs(rename = "_createBufferGroupStart")]
3530 pub fn create_buffer_group_start(
3531 &self,
3532 _ctx: rquickjs::Ctx<'_>,
3533 name: String,
3534 mode: String,
3535 layout_json: String,
3536 ) -> rquickjs::Result<u64> {
3537 let id = {
3538 let mut id_ref = self.next_request_id.borrow_mut();
3539 let id = *id_ref;
3540 *id_ref += 1;
3541 self.callback_contexts
3542 .borrow_mut()
3543 .insert(id, self.plugin_name.clone());
3544 id
3545 };
3546 if let Ok(mut owners) = self.async_resource_owners.lock() {
3547 owners.insert(id, self.plugin_name.clone());
3548 }
3549 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
3550 name,
3551 mode,
3552 layout_json,
3553 request_id: Some(id),
3554 });
3555 Ok(id)
3556 }
3557
3558 #[qjs(rename = "setPanelContent")]
3560 pub fn set_panel_content<'js>(
3561 &self,
3562 ctx: rquickjs::Ctx<'js>,
3563 group_id: u32,
3564 panel_name: String,
3565 entries_arr: Vec<rquickjs::Object<'js>>,
3566 ) -> rquickjs::Result<bool> {
3567 let entries: Vec<TextPropertyEntry> = entries_arr
3568 .iter()
3569 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3570 .collect();
3571 Ok(self
3572 .command_sender
3573 .send(PluginCommand::SetPanelContent {
3574 group_id: group_id as usize,
3575 panel_name,
3576 entries,
3577 })
3578 .is_ok())
3579 }
3580
3581 #[qjs(rename = "closeBufferGroup")]
3583 pub fn close_buffer_group(&self, group_id: u32) -> bool {
3584 self.command_sender
3585 .send(PluginCommand::CloseBufferGroup {
3586 group_id: group_id as usize,
3587 })
3588 .is_ok()
3589 }
3590
3591 #[qjs(rename = "focusBufferGroupPanel")]
3593 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
3594 self.command_sender
3595 .send(PluginCommand::FocusPanel {
3596 group_id: group_id as usize,
3597 panel_name,
3598 })
3599 .is_ok()
3600 }
3601
3602 pub fn set_virtual_buffer_content<'js>(
3606 &self,
3607 ctx: rquickjs::Ctx<'js>,
3608 buffer_id: u32,
3609 entries_arr: Vec<rquickjs::Object<'js>>,
3610 ) -> rquickjs::Result<bool> {
3611 let entries: Vec<TextPropertyEntry> = entries_arr
3612 .iter()
3613 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3614 .collect();
3615 Ok(self
3616 .command_sender
3617 .send(PluginCommand::SetVirtualBufferContent {
3618 buffer_id: BufferId(buffer_id as usize),
3619 entries,
3620 })
3621 .is_ok())
3622 }
3623
3624 pub fn get_text_properties_at_cursor(
3626 &self,
3627 buffer_id: u32,
3628 ) -> fresh_core::api::TextPropertiesAtCursor {
3629 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3630 }
3631
3632 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3636 #[qjs(rename = "_spawnProcessStart")]
3637 pub fn spawn_process_start(
3638 &self,
3639 _ctx: rquickjs::Ctx<'_>,
3640 command: String,
3641 args: Vec<String>,
3642 cwd: rquickjs::function::Opt<String>,
3643 ) -> u64 {
3644 let id = {
3645 let mut id_ref = self.next_request_id.borrow_mut();
3646 let id = *id_ref;
3647 *id_ref += 1;
3648 self.callback_contexts
3650 .borrow_mut()
3651 .insert(id, self.plugin_name.clone());
3652 id
3653 };
3654 let effective_cwd = cwd.0.or_else(|| {
3656 self.state_snapshot
3657 .read()
3658 .ok()
3659 .map(|s| s.working_dir.to_string_lossy().to_string())
3660 });
3661 tracing::info!(
3662 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3663 self.plugin_name,
3664 command,
3665 args,
3666 effective_cwd,
3667 id
3668 );
3669 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3670 callback_id: JsCallbackId::new(id),
3671 command,
3672 args,
3673 cwd: effective_cwd,
3674 });
3675 id
3676 }
3677
3678 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3680 #[qjs(rename = "_spawnProcessWaitStart")]
3681 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3682 let id = {
3683 let mut id_ref = self.next_request_id.borrow_mut();
3684 let id = *id_ref;
3685 *id_ref += 1;
3686 self.callback_contexts
3688 .borrow_mut()
3689 .insert(id, self.plugin_name.clone());
3690 id
3691 };
3692 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3693 process_id,
3694 callback_id: JsCallbackId::new(id),
3695 });
3696 id
3697 }
3698
3699 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3701 #[qjs(rename = "_getBufferTextStart")]
3702 pub fn get_buffer_text_start(
3703 &self,
3704 _ctx: rquickjs::Ctx<'_>,
3705 buffer_id: u32,
3706 start: u32,
3707 end: u32,
3708 ) -> u64 {
3709 let id = {
3710 let mut id_ref = self.next_request_id.borrow_mut();
3711 let id = *id_ref;
3712 *id_ref += 1;
3713 self.callback_contexts
3715 .borrow_mut()
3716 .insert(id, self.plugin_name.clone());
3717 id
3718 };
3719 let _ = self.command_sender.send(PluginCommand::GetBufferText {
3720 buffer_id: BufferId(buffer_id as usize),
3721 start: start as usize,
3722 end: end as usize,
3723 request_id: id,
3724 });
3725 id
3726 }
3727
3728 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3730 #[qjs(rename = "_delayStart")]
3731 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3732 let id = {
3733 let mut id_ref = self.next_request_id.borrow_mut();
3734 let id = *id_ref;
3735 *id_ref += 1;
3736 self.callback_contexts
3738 .borrow_mut()
3739 .insert(id, self.plugin_name.clone());
3740 id
3741 };
3742 let _ = self.command_sender.send(PluginCommand::Delay {
3743 callback_id: JsCallbackId::new(id),
3744 duration_ms,
3745 });
3746 id
3747 }
3748
3749 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
3753 #[qjs(rename = "_grepProjectStart")]
3754 pub fn grep_project_start(
3755 &self,
3756 _ctx: rquickjs::Ctx<'_>,
3757 pattern: String,
3758 fixed_string: Option<bool>,
3759 case_sensitive: Option<bool>,
3760 max_results: Option<u32>,
3761 whole_words: Option<bool>,
3762 ) -> u64 {
3763 let id = {
3764 let mut id_ref = self.next_request_id.borrow_mut();
3765 let id = *id_ref;
3766 *id_ref += 1;
3767 self.callback_contexts
3768 .borrow_mut()
3769 .insert(id, self.plugin_name.clone());
3770 id
3771 };
3772 let _ = self.command_sender.send(PluginCommand::GrepProject {
3773 pattern,
3774 fixed_string: fixed_string.unwrap_or(true),
3775 case_sensitive: case_sensitive.unwrap_or(true),
3776 max_results: max_results.unwrap_or(200) as usize,
3777 whole_words: whole_words.unwrap_or(false),
3778 callback_id: JsCallbackId::new(id),
3779 });
3780 id
3781 }
3782
3783 #[plugin_api(
3787 js_name = "grepProjectStreaming",
3788 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
3789 )]
3790 #[qjs(rename = "_grepProjectStreamingStart")]
3791 pub fn grep_project_streaming_start(
3792 &self,
3793 _ctx: rquickjs::Ctx<'_>,
3794 pattern: String,
3795 fixed_string: bool,
3796 case_sensitive: bool,
3797 max_results: u32,
3798 whole_words: bool,
3799 ) -> u64 {
3800 let id = {
3801 let mut id_ref = self.next_request_id.borrow_mut();
3802 let id = *id_ref;
3803 *id_ref += 1;
3804 self.callback_contexts
3805 .borrow_mut()
3806 .insert(id, self.plugin_name.clone());
3807 id
3808 };
3809 let _ = self
3810 .command_sender
3811 .send(PluginCommand::GrepProjectStreaming {
3812 pattern,
3813 fixed_string,
3814 case_sensitive,
3815 max_results: max_results as usize,
3816 whole_words,
3817 search_id: id,
3818 callback_id: JsCallbackId::new(id),
3819 });
3820 id
3821 }
3822
3823 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
3827 #[qjs(rename = "_replaceInFileStart")]
3828 pub fn replace_in_file_start(
3829 &self,
3830 _ctx: rquickjs::Ctx<'_>,
3831 file_path: String,
3832 matches: Vec<Vec<u32>>,
3833 replacement: String,
3834 ) -> u64 {
3835 let id = {
3836 let mut id_ref = self.next_request_id.borrow_mut();
3837 let id = *id_ref;
3838 *id_ref += 1;
3839 self.callback_contexts
3840 .borrow_mut()
3841 .insert(id, self.plugin_name.clone());
3842 id
3843 };
3844 let match_pairs: Vec<(usize, usize)> = matches
3846 .iter()
3847 .map(|m| (m[0] as usize, m[1] as usize))
3848 .collect();
3849 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
3850 file_path: PathBuf::from(file_path),
3851 matches: match_pairs,
3852 replacement,
3853 callback_id: JsCallbackId::new(id),
3854 });
3855 id
3856 }
3857
3858 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3860 #[qjs(rename = "_sendLspRequestStart")]
3861 pub fn send_lsp_request_start<'js>(
3862 &self,
3863 ctx: rquickjs::Ctx<'js>,
3864 language: String,
3865 method: String,
3866 params: Option<rquickjs::Object<'js>>,
3867 ) -> rquickjs::Result<u64> {
3868 let id = {
3869 let mut id_ref = self.next_request_id.borrow_mut();
3870 let id = *id_ref;
3871 *id_ref += 1;
3872 self.callback_contexts
3874 .borrow_mut()
3875 .insert(id, self.plugin_name.clone());
3876 id
3877 };
3878 let params_json: Option<serde_json::Value> = params.map(|obj| {
3880 let val = obj.into_value();
3881 js_to_json(&ctx, val)
3882 });
3883 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3884 request_id: id,
3885 language,
3886 method,
3887 params: params_json,
3888 });
3889 Ok(id)
3890 }
3891
3892 #[plugin_api(
3894 async_thenable,
3895 js_name = "spawnBackgroundProcess",
3896 ts_return = "BackgroundProcessResult"
3897 )]
3898 #[qjs(rename = "_spawnBackgroundProcessStart")]
3899 pub fn spawn_background_process_start(
3900 &self,
3901 _ctx: rquickjs::Ctx<'_>,
3902 command: String,
3903 args: Vec<String>,
3904 cwd: rquickjs::function::Opt<String>,
3905 ) -> u64 {
3906 let id = {
3907 let mut id_ref = self.next_request_id.borrow_mut();
3908 let id = *id_ref;
3909 *id_ref += 1;
3910 self.callback_contexts
3912 .borrow_mut()
3913 .insert(id, self.plugin_name.clone());
3914 id
3915 };
3916 let process_id = id;
3918 self.plugin_tracked_state
3920 .borrow_mut()
3921 .entry(self.plugin_name.clone())
3922 .or_default()
3923 .background_process_ids
3924 .push(process_id);
3925 let _ = self
3926 .command_sender
3927 .send(PluginCommand::SpawnBackgroundProcess {
3928 process_id,
3929 command,
3930 args,
3931 cwd: cwd.0,
3932 callback_id: JsCallbackId::new(id),
3933 });
3934 id
3935 }
3936
3937 pub fn kill_background_process(&self, process_id: u64) -> bool {
3939 self.command_sender
3940 .send(PluginCommand::KillBackgroundProcess { process_id })
3941 .is_ok()
3942 }
3943
3944 #[plugin_api(
3948 async_promise,
3949 js_name = "createTerminal",
3950 ts_return = "TerminalResult"
3951 )]
3952 #[qjs(rename = "_createTerminalStart")]
3953 pub fn create_terminal_start(
3954 &self,
3955 _ctx: rquickjs::Ctx<'_>,
3956 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3957 ) -> rquickjs::Result<u64> {
3958 let id = {
3959 let mut id_ref = self.next_request_id.borrow_mut();
3960 let id = *id_ref;
3961 *id_ref += 1;
3962 self.callback_contexts
3963 .borrow_mut()
3964 .insert(id, self.plugin_name.clone());
3965 id
3966 };
3967
3968 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3969 cwd: None,
3970 direction: None,
3971 ratio: None,
3972 focus: None,
3973 });
3974
3975 if let Ok(mut owners) = self.async_resource_owners.lock() {
3977 owners.insert(id, self.plugin_name.clone());
3978 }
3979 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3980 cwd: opts.cwd,
3981 direction: opts.direction,
3982 ratio: opts.ratio,
3983 focus: opts.focus,
3984 request_id: id,
3985 });
3986 Ok(id)
3987 }
3988
3989 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3991 self.command_sender
3992 .send(PluginCommand::SendTerminalInput {
3993 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3994 data,
3995 })
3996 .is_ok()
3997 }
3998
3999 pub fn close_terminal(&self, terminal_id: u64) -> bool {
4001 self.command_sender
4002 .send(PluginCommand::CloseTerminal {
4003 terminal_id: fresh_core::TerminalId(terminal_id as usize),
4004 })
4005 .is_ok()
4006 }
4007
4008 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
4012 self.command_sender
4013 .send(PluginCommand::RefreshLines {
4014 buffer_id: BufferId(buffer_id as usize),
4015 })
4016 .is_ok()
4017 }
4018
4019 pub fn get_current_locale(&self) -> String {
4021 self.services.current_locale()
4022 }
4023
4024 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
4028 #[qjs(rename = "_loadPluginStart")]
4029 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
4030 let id = {
4031 let mut id_ref = self.next_request_id.borrow_mut();
4032 let id = *id_ref;
4033 *id_ref += 1;
4034 self.callback_contexts
4035 .borrow_mut()
4036 .insert(id, self.plugin_name.clone());
4037 id
4038 };
4039 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
4040 path: std::path::PathBuf::from(path),
4041 callback_id: JsCallbackId::new(id),
4042 });
4043 id
4044 }
4045
4046 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
4048 #[qjs(rename = "_unloadPluginStart")]
4049 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4050 let id = {
4051 let mut id_ref = self.next_request_id.borrow_mut();
4052 let id = *id_ref;
4053 *id_ref += 1;
4054 self.callback_contexts
4055 .borrow_mut()
4056 .insert(id, self.plugin_name.clone());
4057 id
4058 };
4059 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
4060 name,
4061 callback_id: JsCallbackId::new(id),
4062 });
4063 id
4064 }
4065
4066 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
4068 #[qjs(rename = "_reloadPluginStart")]
4069 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4070 let id = {
4071 let mut id_ref = self.next_request_id.borrow_mut();
4072 let id = *id_ref;
4073 *id_ref += 1;
4074 self.callback_contexts
4075 .borrow_mut()
4076 .insert(id, self.plugin_name.clone());
4077 id
4078 };
4079 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
4080 name,
4081 callback_id: JsCallbackId::new(id),
4082 });
4083 id
4084 }
4085
4086 #[plugin_api(
4089 async_promise,
4090 js_name = "listPlugins",
4091 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
4092 )]
4093 #[qjs(rename = "_listPluginsStart")]
4094 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4095 let id = {
4096 let mut id_ref = self.next_request_id.borrow_mut();
4097 let id = *id_ref;
4098 *id_ref += 1;
4099 self.callback_contexts
4100 .borrow_mut()
4101 .insert(id, self.plugin_name.clone());
4102 id
4103 };
4104 let _ = self.command_sender.send(PluginCommand::ListPlugins {
4105 callback_id: JsCallbackId::new(id),
4106 });
4107 id
4108 }
4109}
4110
4111fn parse_view_token(
4118 obj: &rquickjs::Object<'_>,
4119 idx: usize,
4120) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
4121 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4122
4123 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
4125 from: "object",
4126 to: "ViewTokenWire",
4127 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
4128 })?;
4129
4130 let source_offset: Option<usize> = obj
4132 .get("sourceOffset")
4133 .ok()
4134 .or_else(|| obj.get("source_offset").ok());
4135
4136 let kind = if kind_value.is_string() {
4138 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4141 from: "value",
4142 to: "string",
4143 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
4144 })?;
4145
4146 match kind_str.to_lowercase().as_str() {
4147 "text" => {
4148 let text: String = obj.get("text").unwrap_or_default();
4149 ViewTokenWireKind::Text(text)
4150 }
4151 "newline" => ViewTokenWireKind::Newline,
4152 "space" => ViewTokenWireKind::Space,
4153 "break" => ViewTokenWireKind::Break,
4154 _ => {
4155 tracing::warn!(
4157 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
4158 idx, kind_str
4159 );
4160 return Err(rquickjs::Error::FromJs {
4161 from: "string",
4162 to: "ViewTokenWireKind",
4163 message: Some(format!(
4164 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
4165 idx, kind_str
4166 )),
4167 });
4168 }
4169 }
4170 } else if kind_value.is_object() {
4171 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4173 from: "value",
4174 to: "object",
4175 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
4176 })?;
4177
4178 if let Ok(text) = kind_obj.get::<_, String>("Text") {
4179 ViewTokenWireKind::Text(text)
4180 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
4181 ViewTokenWireKind::BinaryByte(byte)
4182 } else {
4183 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
4185 tracing::warn!(
4186 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
4187 idx,
4188 keys
4189 );
4190 return Err(rquickjs::Error::FromJs {
4191 from: "object",
4192 to: "ViewTokenWireKind",
4193 message: Some(format!(
4194 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
4195 idx, keys
4196 )),
4197 });
4198 }
4199 } else {
4200 tracing::warn!(
4201 "token[{}]: 'kind' field must be a string or object, got: {:?}",
4202 idx,
4203 kind_value.type_of()
4204 );
4205 return Err(rquickjs::Error::FromJs {
4206 from: "value",
4207 to: "ViewTokenWireKind",
4208 message: Some(format!(
4209 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
4210 idx
4211 )),
4212 });
4213 };
4214
4215 let style = parse_view_token_style(obj, idx)?;
4217
4218 Ok(ViewTokenWire {
4219 source_offset,
4220 kind,
4221 style,
4222 })
4223}
4224
4225fn parse_view_token_style(
4227 obj: &rquickjs::Object<'_>,
4228 idx: usize,
4229) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
4230 use fresh_core::api::ViewTokenStyle;
4231
4232 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
4233 let Some(s) = style_obj else {
4234 return Ok(None);
4235 };
4236
4237 let fg: Option<Vec<u8>> = s.get("fg").ok();
4238 let bg: Option<Vec<u8>> = s.get("bg").ok();
4239
4240 let fg_color = if let Some(ref c) = fg {
4242 if c.len() < 3 {
4243 tracing::warn!(
4244 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4245 idx,
4246 c.len()
4247 );
4248 None
4249 } else {
4250 Some((c[0], c[1], c[2]))
4251 }
4252 } else {
4253 None
4254 };
4255
4256 let bg_color = if let Some(ref c) = bg {
4257 if c.len() < 3 {
4258 tracing::warn!(
4259 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4260 idx,
4261 c.len()
4262 );
4263 None
4264 } else {
4265 Some((c[0], c[1], c[2]))
4266 }
4267 } else {
4268 None
4269 };
4270
4271 Ok(Some(ViewTokenStyle {
4272 fg: fg_color,
4273 bg: bg_color,
4274 bold: s.get("bold").unwrap_or(false),
4275 italic: s.get("italic").unwrap_or(false),
4276 }))
4277}
4278
4279pub struct QuickJsBackend {
4281 runtime: Runtime,
4282 main_context: Context,
4284 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4286 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4288 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4290 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4292 command_sender: mpsc::Sender<PluginCommand>,
4294 #[allow(dead_code)]
4296 pending_responses: PendingResponses,
4297 next_request_id: Rc<RefCell<u64>>,
4299 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4301 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4303 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4305 async_resource_owners: AsyncResourceOwners,
4308 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4310 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4312 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4314 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4316}
4317
4318impl QuickJsBackend {
4319 pub fn new() -> Result<Self> {
4321 let (tx, _rx) = mpsc::channel();
4322 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4323 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4324 Self::with_state(state_snapshot, tx, services)
4325 }
4326
4327 pub fn with_state(
4329 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4330 command_sender: mpsc::Sender<PluginCommand>,
4331 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4332 ) -> Result<Self> {
4333 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4334 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4335 }
4336
4337 pub fn with_state_and_responses(
4339 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4340 command_sender: mpsc::Sender<PluginCommand>,
4341 pending_responses: PendingResponses,
4342 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4343 ) -> Result<Self> {
4344 let async_resource_owners: AsyncResourceOwners =
4345 Arc::new(std::sync::Mutex::new(HashMap::new()));
4346 Self::with_state_responses_and_resources(
4347 state_snapshot,
4348 command_sender,
4349 pending_responses,
4350 services,
4351 async_resource_owners,
4352 )
4353 }
4354
4355 pub fn with_state_responses_and_resources(
4358 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4359 command_sender: mpsc::Sender<PluginCommand>,
4360 pending_responses: PendingResponses,
4361 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4362 async_resource_owners: AsyncResourceOwners,
4363 ) -> Result<Self> {
4364 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4365
4366 let runtime =
4367 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4368
4369 runtime.set_host_promise_rejection_tracker(Some(Box::new(
4371 |_ctx, _promise, reason, is_handled| {
4372 if !is_handled {
4373 let error_msg = if let Some(exc) = reason.as_exception() {
4375 format!(
4376 "{}: {}",
4377 exc.message().unwrap_or_default(),
4378 exc.stack().unwrap_or_default()
4379 )
4380 } else {
4381 format!("{:?}", reason)
4382 };
4383
4384 tracing::error!("Unhandled Promise rejection: {}", error_msg);
4385
4386 if should_panic_on_js_errors() {
4387 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4390 set_fatal_js_error(full_msg);
4391 }
4392 }
4393 },
4394 )));
4395
4396 let main_context = Context::full(&runtime)
4397 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4398
4399 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4400 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4401 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4402 let next_request_id = Rc::new(RefCell::new(1u64));
4403 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4404 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4405 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4406 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4407 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4408 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4409
4410 let backend = Self {
4411 runtime,
4412 main_context,
4413 plugin_contexts,
4414 event_handlers,
4415 registered_actions,
4416 state_snapshot,
4417 command_sender,
4418 pending_responses,
4419 next_request_id,
4420 callback_contexts,
4421 services,
4422 plugin_tracked_state,
4423 async_resource_owners,
4424 registered_command_names,
4425 registered_grammar_languages,
4426 registered_language_configs,
4427 registered_lsp_servers,
4428 };
4429
4430 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4432
4433 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4434 Ok(backend)
4435 }
4436
4437 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4439 let state_snapshot = Arc::clone(&self.state_snapshot);
4440 let command_sender = self.command_sender.clone();
4441 let event_handlers = Rc::clone(&self.event_handlers);
4442 let registered_actions = Rc::clone(&self.registered_actions);
4443 let next_request_id = Rc::clone(&self.next_request_id);
4444 let registered_command_names = Rc::clone(&self.registered_command_names);
4445 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4446 let registered_language_configs = Rc::clone(&self.registered_language_configs);
4447 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4448
4449 context.with(|ctx| {
4450 let globals = ctx.globals();
4451
4452 globals.set("__pluginName__", plugin_name)?;
4454
4455 let js_api = JsEditorApi {
4458 state_snapshot: Arc::clone(&state_snapshot),
4459 command_sender: command_sender.clone(),
4460 registered_actions: Rc::clone(®istered_actions),
4461 event_handlers: Rc::clone(&event_handlers),
4462 next_request_id: Rc::clone(&next_request_id),
4463 callback_contexts: Rc::clone(&self.callback_contexts),
4464 services: self.services.clone(),
4465 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4466 async_resource_owners: Arc::clone(&self.async_resource_owners),
4467 registered_command_names: Rc::clone(®istered_command_names),
4468 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
4469 registered_language_configs: Rc::clone(®istered_language_configs),
4470 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
4471 plugin_name: plugin_name.to_string(),
4472 };
4473 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4474
4475 globals.set("editor", editor)?;
4477
4478 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4480
4481 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4483
4484 let console = Object::new(ctx.clone())?;
4487 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4488 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4489 tracing::info!("console.log: {}", parts.join(" "));
4490 })?)?;
4491 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4492 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4493 tracing::warn!("console.warn: {}", parts.join(" "));
4494 })?)?;
4495 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4496 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4497 tracing::error!("console.error: {}", parts.join(" "));
4498 })?)?;
4499 globals.set("console", console)?;
4500
4501 ctx.eval::<(), _>(r#"
4503 // Pending promise callbacks: callbackId -> { resolve, reject }
4504 globalThis._pendingCallbacks = new Map();
4505
4506 // Resolve a pending callback (called from Rust)
4507 globalThis._resolveCallback = function(callbackId, result) {
4508 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4509 const cb = globalThis._pendingCallbacks.get(callbackId);
4510 if (cb) {
4511 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4512 globalThis._pendingCallbacks.delete(callbackId);
4513 cb.resolve(result);
4514 console.log('[JS] _resolveCallback: resolve() called');
4515 } else {
4516 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4517 }
4518 };
4519
4520 // Reject a pending callback (called from Rust)
4521 globalThis._rejectCallback = function(callbackId, error) {
4522 const cb = globalThis._pendingCallbacks.get(callbackId);
4523 if (cb) {
4524 globalThis._pendingCallbacks.delete(callbackId);
4525 cb.reject(new Error(error));
4526 }
4527 };
4528
4529 // Streaming callbacks: called multiple times with partial results
4530 globalThis._streamingCallbacks = new Map();
4531
4532 // Called from Rust with partial data. When done=true, cleans up.
4533 globalThis._callStreamingCallback = function(callbackId, result, done) {
4534 const cb = globalThis._streamingCallbacks.get(callbackId);
4535 if (cb) {
4536 cb(result, done);
4537 if (done) {
4538 globalThis._streamingCallbacks.delete(callbackId);
4539 }
4540 }
4541 };
4542
4543 // Generic async wrapper decorator
4544 // Wraps a function that returns a callbackId into a promise-returning function
4545 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4546 // NOTE: We pass the method name as a string and call via bracket notation
4547 // to preserve rquickjs's automatic Ctx injection for methods
4548 globalThis._wrapAsync = function(methodName, fnName) {
4549 const startFn = editor[methodName];
4550 if (typeof startFn !== 'function') {
4551 // Return a function that always throws - catches missing implementations
4552 return function(...args) {
4553 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4554 editor.debug(`[ASYNC ERROR] ${error.message}`);
4555 throw error;
4556 };
4557 }
4558 return function(...args) {
4559 // Call via bracket notation to preserve method binding and Ctx injection
4560 const callbackId = editor[methodName](...args);
4561 return new Promise((resolve, reject) => {
4562 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4563 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4564 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4565 });
4566 };
4567 };
4568
4569 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4570 // The returned object has .result promise and is itself thenable
4571 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4572 const startFn = editor[methodName];
4573 if (typeof startFn !== 'function') {
4574 // Return a function that always throws - catches missing implementations
4575 return function(...args) {
4576 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4577 editor.debug(`[ASYNC ERROR] ${error.message}`);
4578 throw error;
4579 };
4580 }
4581 return function(...args) {
4582 // Call via bracket notation to preserve method binding and Ctx injection
4583 const callbackId = editor[methodName](...args);
4584 const resultPromise = new Promise((resolve, reject) => {
4585 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4586 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4587 });
4588 return {
4589 get result() { return resultPromise; },
4590 then(onFulfilled, onRejected) {
4591 return resultPromise.then(onFulfilled, onRejected);
4592 },
4593 catch(onRejected) {
4594 return resultPromise.catch(onRejected);
4595 }
4596 };
4597 };
4598 };
4599
4600 // Apply wrappers to async functions on editor
4601 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
4602 editor.delay = _wrapAsync("_delayStart", "delay");
4603 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
4604 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
4605 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
4606 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
4607 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
4608 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
4609 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
4610 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
4611 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
4612 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
4613 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
4614 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
4615 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
4616 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
4617 editor.prompt = _wrapAsync("_promptStart", "prompt");
4618 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
4619 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
4620 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
4621 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
4622 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
4623 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
4624
4625 // Streaming grep: takes a progress callback, returns a thenable with searchId
4626 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
4627 opts = opts || {};
4628 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
4629 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
4630 const maxResults = opts.maxResults || 10000;
4631 const wholeWords = opts.wholeWords || false;
4632
4633 const searchId = editor._grepProjectStreamingStart(
4634 pattern, fixedString, caseSensitive, maxResults, wholeWords
4635 );
4636
4637 // Register streaming callback
4638 if (progressCallback) {
4639 globalThis._streamingCallbacks.set(searchId, progressCallback);
4640 }
4641
4642 // Create completion promise (resolved via _resolveCallback when search finishes)
4643 const resultPromise = new Promise(function(resolve, reject) {
4644 globalThis._pendingCallbacks.set(searchId, {
4645 resolve: function(result) {
4646 globalThis._streamingCallbacks.delete(searchId);
4647 resolve(result);
4648 },
4649 reject: function(err) {
4650 globalThis._streamingCallbacks.delete(searchId);
4651 reject(err);
4652 }
4653 });
4654 });
4655
4656 return {
4657 searchId: searchId,
4658 get result() { return resultPromise; },
4659 then: function(f, r) { return resultPromise.then(f, r); },
4660 catch: function(r) { return resultPromise.catch(r); }
4661 };
4662 };
4663
4664 // Wrapper for deleteTheme - wraps sync function in Promise
4665 editor.deleteTheme = function(name) {
4666 return new Promise(function(resolve, reject) {
4667 const success = editor._deleteThemeSync(name);
4668 if (success) {
4669 resolve();
4670 } else {
4671 reject(new Error("Failed to delete theme: " + name));
4672 }
4673 });
4674 };
4675 "#.as_bytes())?;
4676
4677 Ok::<_, rquickjs::Error>(())
4678 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
4679
4680 Ok(())
4681 }
4682
4683 pub async fn load_module_with_source(
4685 &mut self,
4686 path: &str,
4687 _plugin_source: &str,
4688 ) -> Result<()> {
4689 let path_buf = PathBuf::from(path);
4690 let source = std::fs::read_to_string(&path_buf)
4691 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
4692
4693 let filename = path_buf
4694 .file_name()
4695 .and_then(|s| s.to_str())
4696 .unwrap_or("plugin.ts");
4697
4698 if has_es_imports(&source) {
4700 match bundle_module(&path_buf) {
4702 Ok(bundled) => {
4703 self.execute_js(&bundled, path)?;
4704 }
4705 Err(e) => {
4706 tracing::warn!(
4707 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
4708 path,
4709 e
4710 );
4711 return Ok(()); }
4713 }
4714 } else if has_es_module_syntax(&source) {
4715 let stripped = strip_imports_and_exports(&source);
4717 let js_code = if filename.ends_with(".ts") {
4718 transpile_typescript(&stripped, filename)?
4719 } else {
4720 stripped
4721 };
4722 self.execute_js(&js_code, path)?;
4723 } else {
4724 let js_code = if filename.ends_with(".ts") {
4726 transpile_typescript(&source, filename)?
4727 } else {
4728 source
4729 };
4730 self.execute_js(&js_code, path)?;
4731 }
4732
4733 Ok(())
4734 }
4735
4736 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
4738 let plugin_name = Path::new(source_name)
4740 .file_stem()
4741 .and_then(|s| s.to_str())
4742 .unwrap_or("unknown");
4743
4744 tracing::debug!(
4745 "execute_js: starting for plugin '{}' from '{}'",
4746 plugin_name,
4747 source_name
4748 );
4749
4750 let context = {
4752 let mut contexts = self.plugin_contexts.borrow_mut();
4753 if let Some(ctx) = contexts.get(plugin_name) {
4754 ctx.clone()
4755 } else {
4756 let ctx = Context::full(&self.runtime).map_err(|e| {
4757 anyhow!(
4758 "Failed to create QuickJS context for plugin {}: {}",
4759 plugin_name,
4760 e
4761 )
4762 })?;
4763 self.setup_context_api(&ctx, plugin_name)?;
4764 contexts.insert(plugin_name.to_string(), ctx.clone());
4765 ctx
4766 }
4767 };
4768
4769 let wrapped_code = format!("(function() {{ {} }})();", code);
4773 let wrapped = wrapped_code.as_str();
4774
4775 context.with(|ctx| {
4776 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
4777
4778 let mut eval_options = rquickjs::context::EvalOptions::default();
4780 eval_options.global = true;
4781 eval_options.filename = Some(source_name.to_string());
4782 let result = ctx
4783 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
4784 .map_err(|e| format_js_error(&ctx, e, source_name));
4785
4786 tracing::debug!(
4787 "execute_js: plugin code execution finished for '{}', result: {:?}",
4788 plugin_name,
4789 result.is_ok()
4790 );
4791
4792 result
4793 })
4794 }
4795
4796 pub fn execute_source(
4802 &mut self,
4803 source: &str,
4804 plugin_name: &str,
4805 is_typescript: bool,
4806 ) -> Result<()> {
4807 use fresh_parser_js::{
4808 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4809 };
4810
4811 if has_es_imports(source) {
4812 tracing::warn!(
4813 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4814 plugin_name
4815 );
4816 }
4817
4818 let js_code = if has_es_module_syntax(source) {
4819 let stripped = strip_imports_and_exports(source);
4820 if is_typescript {
4821 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4822 } else {
4823 stripped
4824 }
4825 } else if is_typescript {
4826 transpile_typescript(source, &format!("{}.ts", plugin_name))?
4827 } else {
4828 source.to_string()
4829 };
4830
4831 let source_name = format!(
4833 "{}.{}",
4834 plugin_name,
4835 if is_typescript { "ts" } else { "js" }
4836 );
4837 self.execute_js(&js_code, &source_name)
4838 }
4839
4840 pub fn cleanup_plugin(&self, plugin_name: &str) {
4846 self.plugin_contexts.borrow_mut().remove(plugin_name);
4848
4849 for handlers in self.event_handlers.borrow_mut().values_mut() {
4851 handlers.retain(|h| h.plugin_name != plugin_name);
4852 }
4853
4854 self.registered_actions
4856 .borrow_mut()
4857 .retain(|_, h| h.plugin_name != plugin_name);
4858
4859 self.callback_contexts
4861 .borrow_mut()
4862 .retain(|_, pname| pname != plugin_name);
4863
4864 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4866 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4868 std::collections::HashSet::new();
4869 for (buf_id, ns) in &tracked.overlay_namespaces {
4870 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4871 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4873 buffer_id: *buf_id,
4874 namespace: OverlayNamespace::from_string(ns.clone()),
4875 });
4876 let _ = self
4878 .command_sender
4879 .send(PluginCommand::ClearConcealNamespace {
4880 buffer_id: *buf_id,
4881 namespace: OverlayNamespace::from_string(ns.clone()),
4882 });
4883 let _ = self
4884 .command_sender
4885 .send(PluginCommand::ClearSoftBreakNamespace {
4886 buffer_id: *buf_id,
4887 namespace: OverlayNamespace::from_string(ns.clone()),
4888 });
4889 }
4890 }
4891
4892 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4898 std::collections::HashSet::new();
4899 for (buf_id, ns) in &tracked.line_indicator_namespaces {
4900 if seen_li_ns.insert((buf_id.0, ns.clone())) {
4901 let _ = self
4902 .command_sender
4903 .send(PluginCommand::ClearLineIndicators {
4904 buffer_id: *buf_id,
4905 namespace: ns.clone(),
4906 });
4907 }
4908 }
4909
4910 let mut seen_vt: std::collections::HashSet<(usize, String)> =
4912 std::collections::HashSet::new();
4913 for (buf_id, vt_id) in &tracked.virtual_text_ids {
4914 if seen_vt.insert((buf_id.0, vt_id.clone())) {
4915 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4916 buffer_id: *buf_id,
4917 virtual_text_id: vt_id.clone(),
4918 });
4919 }
4920 }
4921
4922 let mut seen_fe_ns: std::collections::HashSet<String> =
4924 std::collections::HashSet::new();
4925 for ns in &tracked.file_explorer_namespaces {
4926 if seen_fe_ns.insert(ns.clone()) {
4927 let _ = self
4928 .command_sender
4929 .send(PluginCommand::ClearFileExplorerDecorations {
4930 namespace: ns.clone(),
4931 });
4932 }
4933 }
4934
4935 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4937 for ctx_name in &tracked.contexts_set {
4938 if seen_ctx.insert(ctx_name.clone()) {
4939 let _ = self.command_sender.send(PluginCommand::SetContext {
4940 name: ctx_name.clone(),
4941 active: false,
4942 });
4943 }
4944 }
4945
4946 for process_id in &tracked.background_process_ids {
4950 let _ = self
4951 .command_sender
4952 .send(PluginCommand::KillBackgroundProcess {
4953 process_id: *process_id,
4954 });
4955 }
4956
4957 for group_id in &tracked.scroll_sync_group_ids {
4959 let _ = self
4960 .command_sender
4961 .send(PluginCommand::RemoveScrollSyncGroup {
4962 group_id: *group_id,
4963 });
4964 }
4965
4966 for buffer_id in &tracked.virtual_buffer_ids {
4968 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4969 buffer_id: *buffer_id,
4970 });
4971 }
4972
4973 for buffer_id in &tracked.composite_buffer_ids {
4975 let _ = self
4976 .command_sender
4977 .send(PluginCommand::CloseCompositeBuffer {
4978 buffer_id: *buffer_id,
4979 });
4980 }
4981
4982 for terminal_id in &tracked.terminal_ids {
4984 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4985 terminal_id: *terminal_id,
4986 });
4987 }
4988 }
4989
4990 if let Ok(mut owners) = self.async_resource_owners.lock() {
4992 owners.retain(|_, name| name != plugin_name);
4993 }
4994
4995 self.registered_command_names
4997 .borrow_mut()
4998 .retain(|_, pname| pname != plugin_name);
4999 self.registered_grammar_languages
5000 .borrow_mut()
5001 .retain(|_, pname| pname != plugin_name);
5002 self.registered_language_configs
5003 .borrow_mut()
5004 .retain(|_, pname| pname != plugin_name);
5005 self.registered_lsp_servers
5006 .borrow_mut()
5007 .retain(|_, pname| pname != plugin_name);
5008
5009 tracing::debug!(
5010 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
5011 plugin_name
5012 );
5013 }
5014
5015 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
5017 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
5018
5019 self.services
5020 .set_js_execution_state(format!("hook '{}'", event_name));
5021
5022 let handlers = self.event_handlers.borrow().get(event_name).cloned();
5023 if let Some(handler_pairs) = handlers {
5024 let plugin_contexts = self.plugin_contexts.borrow();
5025 for handler in &handler_pairs {
5026 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
5027 continue;
5028 };
5029 context.with(|ctx| {
5030 call_handler(&ctx, &handler.handler_name, event_data);
5031 });
5032 }
5033 }
5034
5035 self.services.clear_js_execution_state();
5036 Ok(true)
5037 }
5038
5039 pub fn has_handlers(&self, event_name: &str) -> bool {
5041 self.event_handlers
5042 .borrow()
5043 .get(event_name)
5044 .map(|v| !v.is_empty())
5045 .unwrap_or(false)
5046 }
5047
5048 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
5052 let (lookup_name, text_input_char) =
5055 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
5056 ("mode_text_input", Some(ch.to_string()))
5057 } else {
5058 (action_name, None)
5059 };
5060
5061 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
5062 let (plugin_name, function_name) = match pair {
5063 Some(handler) => (handler.plugin_name, handler.handler_name),
5064 None => ("main".to_string(), lookup_name.to_string()),
5065 };
5066
5067 let plugin_contexts = self.plugin_contexts.borrow();
5068 let context = plugin_contexts
5069 .get(&plugin_name)
5070 .unwrap_or(&self.main_context);
5071
5072 self.services
5074 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
5075
5076 tracing::info!(
5077 "start_action: BEGIN '{}' -> function '{}'",
5078 action_name,
5079 function_name
5080 );
5081
5082 let call_args = if let Some(ref ch) = text_input_char {
5085 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
5086 format!("({{text:\"{}\"}})", escaped)
5087 } else {
5088 "()".to_string()
5089 };
5090
5091 let code = format!(
5092 r#"
5093 (function() {{
5094 console.log('[JS] start_action: calling {fn}');
5095 try {{
5096 if (typeof globalThis.{fn} === 'function') {{
5097 console.log('[JS] start_action: {fn} is a function, invoking...');
5098 globalThis.{fn}{args};
5099 console.log('[JS] start_action: {fn} invoked (may be async)');
5100 }} else {{
5101 console.error('[JS] Action {action} is not defined as a global function');
5102 }}
5103 }} catch (e) {{
5104 console.error('[JS] Action {action} error:', e);
5105 }}
5106 }})();
5107 "#,
5108 fn = function_name,
5109 action = action_name,
5110 args = call_args
5111 );
5112
5113 tracing::info!("start_action: evaluating JS code");
5114 context.with(|ctx| {
5115 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5116 log_js_error(&ctx, e, &format!("action {}", action_name));
5117 }
5118 tracing::info!("start_action: running pending microtasks");
5119 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
5121 tracing::info!("start_action: executed {} pending jobs", count);
5122 });
5123
5124 tracing::info!("start_action: END '{}'", action_name);
5125
5126 self.services.clear_js_execution_state();
5128
5129 Ok(())
5130 }
5131
5132 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
5134 let pair = self.registered_actions.borrow().get(action_name).cloned();
5136 let (plugin_name, function_name) = match pair {
5137 Some(handler) => (handler.plugin_name, handler.handler_name),
5138 None => ("main".to_string(), action_name.to_string()),
5139 };
5140
5141 let plugin_contexts = self.plugin_contexts.borrow();
5142 let context = plugin_contexts
5143 .get(&plugin_name)
5144 .unwrap_or(&self.main_context);
5145
5146 tracing::debug!(
5147 "execute_action: '{}' -> function '{}'",
5148 action_name,
5149 function_name
5150 );
5151
5152 let code = format!(
5155 r#"
5156 (async function() {{
5157 try {{
5158 if (typeof globalThis.{fn} === 'function') {{
5159 const result = globalThis.{fn}();
5160 // If it's a Promise, await it
5161 if (result && typeof result.then === 'function') {{
5162 await result;
5163 }}
5164 }} else {{
5165 console.error('Action {action} is not defined as a global function');
5166 }}
5167 }} catch (e) {{
5168 console.error('Action {action} error:', e);
5169 }}
5170 }})();
5171 "#,
5172 fn = function_name,
5173 action = action_name
5174 );
5175
5176 context.with(|ctx| {
5177 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5179 Ok(value) => {
5180 if value.is_object() {
5182 if let Some(obj) = value.as_object() {
5183 if obj.get::<_, rquickjs::Function>("then").is_ok() {
5185 run_pending_jobs_checked(
5188 &ctx,
5189 &format!("execute_action {} promise", action_name),
5190 );
5191 }
5192 }
5193 }
5194 }
5195 Err(e) => {
5196 log_js_error(&ctx, e, &format!("action {}", action_name));
5197 }
5198 }
5199 });
5200
5201 Ok(())
5202 }
5203
5204 pub fn poll_event_loop_once(&mut self) -> bool {
5206 let mut had_work = false;
5207
5208 self.main_context.with(|ctx| {
5210 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
5211 if count > 0 {
5212 had_work = true;
5213 }
5214 });
5215
5216 let contexts = self.plugin_contexts.borrow().clone();
5218 for (name, context) in contexts {
5219 context.with(|ctx| {
5220 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
5221 if count > 0 {
5222 had_work = true;
5223 }
5224 });
5225 }
5226 had_work
5227 }
5228
5229 pub fn send_status(&self, message: String) {
5231 let _ = self
5232 .command_sender
5233 .send(PluginCommand::SetStatus { message });
5234 }
5235
5236 pub fn send_hook_completed(&self, hook_name: String) {
5240 let _ = self
5241 .command_sender
5242 .send(PluginCommand::HookCompleted { hook_name });
5243 }
5244
5245 pub fn resolve_callback(
5250 &mut self,
5251 callback_id: fresh_core::api::JsCallbackId,
5252 result_json: &str,
5253 ) {
5254 let id = callback_id.as_u64();
5255 tracing::debug!("resolve_callback: starting for callback_id={}", id);
5256
5257 let plugin_name = {
5259 let mut contexts = self.callback_contexts.borrow_mut();
5260 contexts.remove(&id)
5261 };
5262
5263 let Some(name) = plugin_name else {
5264 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5265 return;
5266 };
5267
5268 let plugin_contexts = self.plugin_contexts.borrow();
5269 let Some(context) = plugin_contexts.get(&name) else {
5270 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5271 return;
5272 };
5273
5274 context.with(|ctx| {
5275 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5277 Ok(v) => v,
5278 Err(e) => {
5279 tracing::error!(
5280 "resolve_callback: failed to parse JSON for callback_id={}: {}",
5281 id,
5282 e
5283 );
5284 return;
5285 }
5286 };
5287
5288 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5290 Ok(v) => v,
5291 Err(e) => {
5292 tracing::error!(
5293 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5294 id,
5295 e
5296 );
5297 return;
5298 }
5299 };
5300
5301 let globals = ctx.globals();
5303 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5304 Ok(f) => f,
5305 Err(e) => {
5306 tracing::error!(
5307 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5308 id,
5309 e
5310 );
5311 return;
5312 }
5313 };
5314
5315 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5317 log_js_error(&ctx, e, &format!("resolving callback {}", id));
5318 }
5319
5320 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5322 tracing::info!(
5323 "resolve_callback: executed {} pending jobs for callback_id={}",
5324 job_count,
5325 id
5326 );
5327 });
5328 }
5329
5330 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5332 let id = callback_id.as_u64();
5333
5334 let plugin_name = {
5336 let mut contexts = self.callback_contexts.borrow_mut();
5337 contexts.remove(&id)
5338 };
5339
5340 let Some(name) = plugin_name else {
5341 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5342 return;
5343 };
5344
5345 let plugin_contexts = self.plugin_contexts.borrow();
5346 let Some(context) = plugin_contexts.get(&name) else {
5347 tracing::warn!("reject_callback: Context lost for plugin {}", name);
5348 return;
5349 };
5350
5351 context.with(|ctx| {
5352 let globals = ctx.globals();
5354 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5355 Ok(f) => f,
5356 Err(e) => {
5357 tracing::error!(
5358 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5359 id,
5360 e
5361 );
5362 return;
5363 }
5364 };
5365
5366 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5368 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5369 }
5370
5371 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5373 });
5374 }
5375
5376 pub fn call_streaming_callback(
5380 &mut self,
5381 callback_id: fresh_core::api::JsCallbackId,
5382 result_json: &str,
5383 done: bool,
5384 ) {
5385 let id = callback_id.as_u64();
5386
5387 let plugin_name = {
5389 let contexts = self.callback_contexts.borrow();
5390 contexts.get(&id).cloned()
5391 };
5392
5393 let Some(name) = plugin_name else {
5394 tracing::warn!(
5395 "call_streaming_callback: No plugin found for callback_id={}",
5396 id
5397 );
5398 return;
5399 };
5400
5401 if done {
5403 self.callback_contexts.borrow_mut().remove(&id);
5404 }
5405
5406 let plugin_contexts = self.plugin_contexts.borrow();
5407 let Some(context) = plugin_contexts.get(&name) else {
5408 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5409 return;
5410 };
5411
5412 context.with(|ctx| {
5413 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5414 Ok(v) => v,
5415 Err(e) => {
5416 tracing::error!(
5417 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5418 id,
5419 e
5420 );
5421 return;
5422 }
5423 };
5424
5425 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5426 Ok(v) => v,
5427 Err(e) => {
5428 tracing::error!(
5429 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5430 id,
5431 e
5432 );
5433 return;
5434 }
5435 };
5436
5437 let globals = ctx.globals();
5438 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5439 Ok(f) => f,
5440 Err(e) => {
5441 tracing::error!(
5442 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5443 id,
5444 e
5445 );
5446 return;
5447 }
5448 };
5449
5450 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5451 log_js_error(
5452 &ctx,
5453 e,
5454 &format!("calling streaming callback {}", id),
5455 );
5456 }
5457
5458 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5459 });
5460 }
5461}
5462
5463#[cfg(test)]
5464mod tests {
5465 use super::*;
5466 use fresh_core::api::{BufferInfo, CursorInfo};
5467 use std::sync::mpsc;
5468
5469 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5471 let (tx, rx) = mpsc::channel();
5472 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5473 let services = Arc::new(TestServiceBridge::new());
5474 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5475 (backend, rx)
5476 }
5477
5478 struct TestServiceBridge {
5479 en_strings: std::sync::Mutex<HashMap<String, String>>,
5480 }
5481
5482 impl TestServiceBridge {
5483 fn new() -> Self {
5484 Self {
5485 en_strings: std::sync::Mutex::new(HashMap::new()),
5486 }
5487 }
5488 }
5489
5490 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5491 fn as_any(&self) -> &dyn std::any::Any {
5492 self
5493 }
5494 fn translate(
5495 &self,
5496 _plugin_name: &str,
5497 key: &str,
5498 _args: &HashMap<String, String>,
5499 ) -> String {
5500 self.en_strings
5501 .lock()
5502 .unwrap()
5503 .get(key)
5504 .cloned()
5505 .unwrap_or_else(|| key.to_string())
5506 }
5507 fn current_locale(&self) -> String {
5508 "en".to_string()
5509 }
5510 fn set_js_execution_state(&self, _state: String) {}
5511 fn clear_js_execution_state(&self) {}
5512 fn get_theme_schema(&self) -> serde_json::Value {
5513 serde_json::json!({})
5514 }
5515 fn get_builtin_themes(&self) -> serde_json::Value {
5516 serde_json::json!([])
5517 }
5518 fn register_command(&self, _command: fresh_core::command::Command) {}
5519 fn unregister_command(&self, _name: &str) {}
5520 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5521 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5522 fn plugins_dir(&self) -> std::path::PathBuf {
5523 std::path::PathBuf::from("/tmp/plugins")
5524 }
5525 fn config_dir(&self) -> std::path::PathBuf {
5526 std::path::PathBuf::from("/tmp/config")
5527 }
5528 fn data_dir(&self) -> std::path::PathBuf {
5529 std::path::PathBuf::from("/tmp/data")
5530 }
5531 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
5532 None
5533 }
5534 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
5535 Err("not implemented in test".to_string())
5536 }
5537 fn theme_file_exists(&self, _name: &str) -> bool {
5538 false
5539 }
5540 }
5541
5542 #[test]
5543 fn test_quickjs_backend_creation() {
5544 let backend = QuickJsBackend::new();
5545 assert!(backend.is_ok());
5546 }
5547
5548 #[test]
5549 fn test_execute_simple_js() {
5550 let mut backend = QuickJsBackend::new().unwrap();
5551 let result = backend.execute_js("const x = 1 + 2;", "test.js");
5552 assert!(result.is_ok());
5553 }
5554
5555 #[test]
5556 fn test_event_handler_registration() {
5557 let backend = QuickJsBackend::new().unwrap();
5558
5559 assert!(!backend.has_handlers("test_event"));
5561
5562 backend
5564 .event_handlers
5565 .borrow_mut()
5566 .entry("test_event".to_string())
5567 .or_default()
5568 .push(PluginHandler {
5569 plugin_name: "test".to_string(),
5570 handler_name: "testHandler".to_string(),
5571 });
5572
5573 assert!(backend.has_handlers("test_event"));
5575 }
5576
5577 #[test]
5580 fn test_api_set_status() {
5581 let (mut backend, rx) = create_test_backend();
5582
5583 backend
5584 .execute_js(
5585 r#"
5586 const editor = getEditor();
5587 editor.setStatus("Hello from test");
5588 "#,
5589 "test.js",
5590 )
5591 .unwrap();
5592
5593 let cmd = rx.try_recv().unwrap();
5594 match cmd {
5595 PluginCommand::SetStatus { message } => {
5596 assert_eq!(message, "Hello from test");
5597 }
5598 _ => panic!("Expected SetStatus command, got {:?}", cmd),
5599 }
5600 }
5601
5602 #[test]
5603 fn test_api_register_command() {
5604 let (mut backend, rx) = create_test_backend();
5605
5606 backend
5607 .execute_js(
5608 r#"
5609 const editor = getEditor();
5610 globalThis.myTestHandler = function() { };
5611 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
5612 "#,
5613 "test_plugin.js",
5614 )
5615 .unwrap();
5616
5617 let cmd = rx.try_recv().unwrap();
5618 match cmd {
5619 PluginCommand::RegisterCommand { command } => {
5620 assert_eq!(command.name, "Test Command");
5621 assert_eq!(command.description, "A test command");
5622 assert_eq!(command.plugin_name, "test_plugin");
5624 }
5625 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
5626 }
5627 }
5628
5629 #[test]
5630 fn test_api_define_mode() {
5631 let (mut backend, rx) = create_test_backend();
5632
5633 backend
5634 .execute_js(
5635 r#"
5636 const editor = getEditor();
5637 editor.defineMode("test-mode", [
5638 ["a", "action_a"],
5639 ["b", "action_b"]
5640 ]);
5641 "#,
5642 "test.js",
5643 )
5644 .unwrap();
5645
5646 let cmd = rx.try_recv().unwrap();
5647 match cmd {
5648 PluginCommand::DefineMode {
5649 name,
5650 bindings,
5651 read_only,
5652 allow_text_input,
5653 inherit_normal_bindings,
5654 plugin_name,
5655 } => {
5656 assert_eq!(name, "test-mode");
5657 assert_eq!(bindings.len(), 2);
5658 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
5659 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
5660 assert!(!read_only);
5661 assert!(!allow_text_input);
5662 assert!(!inherit_normal_bindings);
5663 assert!(plugin_name.is_some());
5664 }
5665 _ => panic!("Expected DefineMode, got {:?}", cmd),
5666 }
5667 }
5668
5669 #[test]
5670 fn test_api_set_editor_mode() {
5671 let (mut backend, rx) = create_test_backend();
5672
5673 backend
5674 .execute_js(
5675 r#"
5676 const editor = getEditor();
5677 editor.setEditorMode("vi-normal");
5678 "#,
5679 "test.js",
5680 )
5681 .unwrap();
5682
5683 let cmd = rx.try_recv().unwrap();
5684 match cmd {
5685 PluginCommand::SetEditorMode { mode } => {
5686 assert_eq!(mode, Some("vi-normal".to_string()));
5687 }
5688 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
5689 }
5690 }
5691
5692 #[test]
5693 fn test_api_clear_editor_mode() {
5694 let (mut backend, rx) = create_test_backend();
5695
5696 backend
5697 .execute_js(
5698 r#"
5699 const editor = getEditor();
5700 editor.setEditorMode(null);
5701 "#,
5702 "test.js",
5703 )
5704 .unwrap();
5705
5706 let cmd = rx.try_recv().unwrap();
5707 match cmd {
5708 PluginCommand::SetEditorMode { mode } => {
5709 assert!(mode.is_none());
5710 }
5711 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
5712 }
5713 }
5714
5715 #[test]
5716 fn test_api_insert_at_cursor() {
5717 let (mut backend, rx) = create_test_backend();
5718
5719 backend
5720 .execute_js(
5721 r#"
5722 const editor = getEditor();
5723 editor.insertAtCursor("Hello, World!");
5724 "#,
5725 "test.js",
5726 )
5727 .unwrap();
5728
5729 let cmd = rx.try_recv().unwrap();
5730 match cmd {
5731 PluginCommand::InsertAtCursor { text } => {
5732 assert_eq!(text, "Hello, World!");
5733 }
5734 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
5735 }
5736 }
5737
5738 #[test]
5739 fn test_api_set_context() {
5740 let (mut backend, rx) = create_test_backend();
5741
5742 backend
5743 .execute_js(
5744 r#"
5745 const editor = getEditor();
5746 editor.setContext("myContext", true);
5747 "#,
5748 "test.js",
5749 )
5750 .unwrap();
5751
5752 let cmd = rx.try_recv().unwrap();
5753 match cmd {
5754 PluginCommand::SetContext { name, active } => {
5755 assert_eq!(name, "myContext");
5756 assert!(active);
5757 }
5758 _ => panic!("Expected SetContext, got {:?}", cmd),
5759 }
5760 }
5761
5762 #[tokio::test]
5763 async fn test_execute_action_sync_function() {
5764 let (mut backend, rx) = create_test_backend();
5765
5766 backend.registered_actions.borrow_mut().insert(
5768 "my_sync_action".to_string(),
5769 PluginHandler {
5770 plugin_name: "test".to_string(),
5771 handler_name: "my_sync_action".to_string(),
5772 },
5773 );
5774
5775 backend
5777 .execute_js(
5778 r#"
5779 const editor = getEditor();
5780 globalThis.my_sync_action = function() {
5781 editor.setStatus("sync action executed");
5782 };
5783 "#,
5784 "test.js",
5785 )
5786 .unwrap();
5787
5788 while rx.try_recv().is_ok() {}
5790
5791 backend.execute_action("my_sync_action").await.unwrap();
5793
5794 let cmd = rx.try_recv().unwrap();
5796 match cmd {
5797 PluginCommand::SetStatus { message } => {
5798 assert_eq!(message, "sync action executed");
5799 }
5800 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
5801 }
5802 }
5803
5804 #[tokio::test]
5805 async fn test_execute_action_async_function() {
5806 let (mut backend, rx) = create_test_backend();
5807
5808 backend.registered_actions.borrow_mut().insert(
5810 "my_async_action".to_string(),
5811 PluginHandler {
5812 plugin_name: "test".to_string(),
5813 handler_name: "my_async_action".to_string(),
5814 },
5815 );
5816
5817 backend
5819 .execute_js(
5820 r#"
5821 const editor = getEditor();
5822 globalThis.my_async_action = async function() {
5823 await Promise.resolve();
5824 editor.setStatus("async action executed");
5825 };
5826 "#,
5827 "test.js",
5828 )
5829 .unwrap();
5830
5831 while rx.try_recv().is_ok() {}
5833
5834 backend.execute_action("my_async_action").await.unwrap();
5836
5837 let cmd = rx.try_recv().unwrap();
5839 match cmd {
5840 PluginCommand::SetStatus { message } => {
5841 assert_eq!(message, "async action executed");
5842 }
5843 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
5844 }
5845 }
5846
5847 #[tokio::test]
5848 async fn test_execute_action_with_registered_handler() {
5849 let (mut backend, rx) = create_test_backend();
5850
5851 backend.registered_actions.borrow_mut().insert(
5853 "my_action".to_string(),
5854 PluginHandler {
5855 plugin_name: "test".to_string(),
5856 handler_name: "actual_handler_function".to_string(),
5857 },
5858 );
5859
5860 backend
5861 .execute_js(
5862 r#"
5863 const editor = getEditor();
5864 globalThis.actual_handler_function = function() {
5865 editor.setStatus("handler executed");
5866 };
5867 "#,
5868 "test.js",
5869 )
5870 .unwrap();
5871
5872 while rx.try_recv().is_ok() {}
5874
5875 backend.execute_action("my_action").await.unwrap();
5877
5878 let cmd = rx.try_recv().unwrap();
5879 match cmd {
5880 PluginCommand::SetStatus { message } => {
5881 assert_eq!(message, "handler executed");
5882 }
5883 _ => panic!("Expected SetStatus, got {:?}", cmd),
5884 }
5885 }
5886
5887 #[test]
5888 fn test_api_on_event_registration() {
5889 let (mut backend, _rx) = create_test_backend();
5890
5891 backend
5892 .execute_js(
5893 r#"
5894 const editor = getEditor();
5895 globalThis.myEventHandler = function() { };
5896 editor.on("bufferSave", "myEventHandler");
5897 "#,
5898 "test.js",
5899 )
5900 .unwrap();
5901
5902 assert!(backend.has_handlers("bufferSave"));
5903 }
5904
5905 #[test]
5906 fn test_api_off_event_unregistration() {
5907 let (mut backend, _rx) = create_test_backend();
5908
5909 backend
5910 .execute_js(
5911 r#"
5912 const editor = getEditor();
5913 globalThis.myEventHandler = function() { };
5914 editor.on("bufferSave", "myEventHandler");
5915 editor.off("bufferSave", "myEventHandler");
5916 "#,
5917 "test.js",
5918 )
5919 .unwrap();
5920
5921 assert!(!backend.has_handlers("bufferSave"));
5923 }
5924
5925 #[tokio::test]
5926 async fn test_emit_event() {
5927 let (mut backend, rx) = create_test_backend();
5928
5929 backend
5930 .execute_js(
5931 r#"
5932 const editor = getEditor();
5933 globalThis.onSaveHandler = function(data) {
5934 editor.setStatus("saved: " + JSON.stringify(data));
5935 };
5936 editor.on("bufferSave", "onSaveHandler");
5937 "#,
5938 "test.js",
5939 )
5940 .unwrap();
5941
5942 while rx.try_recv().is_ok() {}
5944
5945 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5947 backend.emit("bufferSave", &event_data).await.unwrap();
5948
5949 let cmd = rx.try_recv().unwrap();
5950 match cmd {
5951 PluginCommand::SetStatus { message } => {
5952 assert!(message.contains("/test.txt"));
5953 }
5954 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5955 }
5956 }
5957
5958 #[test]
5959 fn test_api_copy_to_clipboard() {
5960 let (mut backend, rx) = create_test_backend();
5961
5962 backend
5963 .execute_js(
5964 r#"
5965 const editor = getEditor();
5966 editor.copyToClipboard("clipboard text");
5967 "#,
5968 "test.js",
5969 )
5970 .unwrap();
5971
5972 let cmd = rx.try_recv().unwrap();
5973 match cmd {
5974 PluginCommand::SetClipboard { text } => {
5975 assert_eq!(text, "clipboard text");
5976 }
5977 _ => panic!("Expected SetClipboard, got {:?}", cmd),
5978 }
5979 }
5980
5981 #[test]
5982 fn test_api_open_file() {
5983 let (mut backend, rx) = create_test_backend();
5984
5985 backend
5987 .execute_js(
5988 r#"
5989 const editor = getEditor();
5990 editor.openFile("/path/to/file.txt", null, null);
5991 "#,
5992 "test.js",
5993 )
5994 .unwrap();
5995
5996 let cmd = rx.try_recv().unwrap();
5997 match cmd {
5998 PluginCommand::OpenFileAtLocation { path, line, column } => {
5999 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
6000 assert!(line.is_none());
6001 assert!(column.is_none());
6002 }
6003 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
6004 }
6005 }
6006
6007 #[test]
6008 fn test_api_delete_range() {
6009 let (mut backend, rx) = create_test_backend();
6010
6011 backend
6013 .execute_js(
6014 r#"
6015 const editor = getEditor();
6016 editor.deleteRange(0, 10, 20);
6017 "#,
6018 "test.js",
6019 )
6020 .unwrap();
6021
6022 let cmd = rx.try_recv().unwrap();
6023 match cmd {
6024 PluginCommand::DeleteRange { range, .. } => {
6025 assert_eq!(range.start, 10);
6026 assert_eq!(range.end, 20);
6027 }
6028 _ => panic!("Expected DeleteRange, got {:?}", cmd),
6029 }
6030 }
6031
6032 #[test]
6033 fn test_api_insert_text() {
6034 let (mut backend, rx) = create_test_backend();
6035
6036 backend
6038 .execute_js(
6039 r#"
6040 const editor = getEditor();
6041 editor.insertText(0, 5, "inserted");
6042 "#,
6043 "test.js",
6044 )
6045 .unwrap();
6046
6047 let cmd = rx.try_recv().unwrap();
6048 match cmd {
6049 PluginCommand::InsertText { position, text, .. } => {
6050 assert_eq!(position, 5);
6051 assert_eq!(text, "inserted");
6052 }
6053 _ => panic!("Expected InsertText, got {:?}", cmd),
6054 }
6055 }
6056
6057 #[test]
6058 fn test_api_set_buffer_cursor() {
6059 let (mut backend, rx) = create_test_backend();
6060
6061 backend
6063 .execute_js(
6064 r#"
6065 const editor = getEditor();
6066 editor.setBufferCursor(0, 100);
6067 "#,
6068 "test.js",
6069 )
6070 .unwrap();
6071
6072 let cmd = rx.try_recv().unwrap();
6073 match cmd {
6074 PluginCommand::SetBufferCursor { position, .. } => {
6075 assert_eq!(position, 100);
6076 }
6077 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
6078 }
6079 }
6080
6081 #[test]
6082 fn test_api_get_cursor_position_from_state() {
6083 let (tx, _rx) = mpsc::channel();
6084 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6085
6086 {
6088 let mut state = state_snapshot.write().unwrap();
6089 state.primary_cursor = Some(CursorInfo {
6090 position: 42,
6091 selection: None,
6092 });
6093 }
6094
6095 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6096 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6097
6098 backend
6100 .execute_js(
6101 r#"
6102 const editor = getEditor();
6103 const pos = editor.getCursorPosition();
6104 globalThis._testResult = pos;
6105 "#,
6106 "test.js",
6107 )
6108 .unwrap();
6109
6110 backend
6112 .plugin_contexts
6113 .borrow()
6114 .get("test")
6115 .unwrap()
6116 .clone()
6117 .with(|ctx| {
6118 let global = ctx.globals();
6119 let result: u32 = global.get("_testResult").unwrap();
6120 assert_eq!(result, 42);
6121 });
6122 }
6123
6124 #[test]
6125 fn test_api_path_functions() {
6126 let (mut backend, _rx) = create_test_backend();
6127
6128 #[cfg(windows)]
6131 let absolute_path = r#"C:\\foo\\bar"#;
6132 #[cfg(not(windows))]
6133 let absolute_path = "/foo/bar";
6134
6135 let js_code = format!(
6137 r#"
6138 const editor = getEditor();
6139 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
6140 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
6141 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
6142 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
6143 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
6144 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
6145 "#,
6146 absolute_path
6147 );
6148 backend.execute_js(&js_code, "test.js").unwrap();
6149
6150 backend
6151 .plugin_contexts
6152 .borrow()
6153 .get("test")
6154 .unwrap()
6155 .clone()
6156 .with(|ctx| {
6157 let global = ctx.globals();
6158 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
6159 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
6160 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
6161 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
6162 assert!(!global.get::<_, bool>("_isRelative").unwrap());
6163 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
6164 });
6165 }
6166
6167 #[test]
6168 fn test_file_uri_to_path_and_back() {
6169 let (mut backend, _rx) = create_test_backend();
6170
6171 #[cfg(not(windows))]
6173 let js_code = r#"
6174 const editor = getEditor();
6175 // Basic file URI to path
6176 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
6177 // Percent-encoded characters
6178 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
6179 // Invalid URI returns empty string
6180 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6181 // Path to file URI
6182 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
6183 // Round-trip
6184 globalThis._roundtrip = editor.fileUriToPath(
6185 editor.pathToFileUri("/home/user/file.txt")
6186 );
6187 "#;
6188
6189 #[cfg(windows)]
6190 let js_code = r#"
6191 const editor = getEditor();
6192 // Windows URI with encoded colon (the bug from issue #1071)
6193 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
6194 // Windows URI with normal colon
6195 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
6196 // Invalid URI returns empty string
6197 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6198 // Path to file URI
6199 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
6200 // Round-trip
6201 globalThis._roundtrip = editor.fileUriToPath(
6202 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
6203 );
6204 "#;
6205
6206 backend.execute_js(js_code, "test.js").unwrap();
6207
6208 backend
6209 .plugin_contexts
6210 .borrow()
6211 .get("test")
6212 .unwrap()
6213 .clone()
6214 .with(|ctx| {
6215 let global = ctx.globals();
6216
6217 #[cfg(not(windows))]
6218 {
6219 assert_eq!(
6220 global.get::<_, String>("_path1").unwrap(),
6221 "/home/user/file.txt"
6222 );
6223 assert_eq!(
6224 global.get::<_, String>("_path2").unwrap(),
6225 "/home/user/my file.txt"
6226 );
6227 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6228 assert_eq!(
6229 global.get::<_, String>("_uri1").unwrap(),
6230 "file:///home/user/file.txt"
6231 );
6232 assert_eq!(
6233 global.get::<_, String>("_roundtrip").unwrap(),
6234 "/home/user/file.txt"
6235 );
6236 }
6237
6238 #[cfg(windows)]
6239 {
6240 assert_eq!(
6242 global.get::<_, String>("_path1").unwrap(),
6243 "C:\\Users\\admin\\Repos\\file.cs"
6244 );
6245 assert_eq!(
6246 global.get::<_, String>("_path2").unwrap(),
6247 "C:\\Users\\admin\\Repos\\file.cs"
6248 );
6249 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6250 assert_eq!(
6251 global.get::<_, String>("_uri1").unwrap(),
6252 "file:///C:/Users/admin/Repos/file.cs"
6253 );
6254 assert_eq!(
6255 global.get::<_, String>("_roundtrip").unwrap(),
6256 "C:\\Users\\admin\\Repos\\file.cs"
6257 );
6258 }
6259 });
6260 }
6261
6262 #[test]
6263 fn test_typescript_transpilation() {
6264 use fresh_parser_js::transpile_typescript;
6265
6266 let (mut backend, rx) = create_test_backend();
6267
6268 let ts_code = r#"
6270 const editor = getEditor();
6271 function greet(name: string): string {
6272 return "Hello, " + name;
6273 }
6274 editor.setStatus(greet("TypeScript"));
6275 "#;
6276
6277 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6279
6280 backend.execute_js(&js_code, "test.js").unwrap();
6282
6283 let cmd = rx.try_recv().unwrap();
6284 match cmd {
6285 PluginCommand::SetStatus { message } => {
6286 assert_eq!(message, "Hello, TypeScript");
6287 }
6288 _ => panic!("Expected SetStatus, got {:?}", cmd),
6289 }
6290 }
6291
6292 #[test]
6293 fn test_api_get_buffer_text_sends_command() {
6294 let (mut backend, rx) = create_test_backend();
6295
6296 backend
6298 .execute_js(
6299 r#"
6300 const editor = getEditor();
6301 // Store the promise for later
6302 globalThis._textPromise = editor.getBufferText(0, 10, 20);
6303 "#,
6304 "test.js",
6305 )
6306 .unwrap();
6307
6308 let cmd = rx.try_recv().unwrap();
6310 match cmd {
6311 PluginCommand::GetBufferText {
6312 buffer_id,
6313 start,
6314 end,
6315 request_id,
6316 } => {
6317 assert_eq!(buffer_id.0, 0);
6318 assert_eq!(start, 10);
6319 assert_eq!(end, 20);
6320 assert!(request_id > 0); }
6322 _ => panic!("Expected GetBufferText, got {:?}", cmd),
6323 }
6324 }
6325
6326 #[test]
6327 fn test_api_get_buffer_text_resolves_callback() {
6328 let (mut backend, rx) = create_test_backend();
6329
6330 backend
6332 .execute_js(
6333 r#"
6334 const editor = getEditor();
6335 globalThis._resolvedText = null;
6336 editor.getBufferText(0, 0, 100).then(text => {
6337 globalThis._resolvedText = text;
6338 });
6339 "#,
6340 "test.js",
6341 )
6342 .unwrap();
6343
6344 let request_id = match rx.try_recv().unwrap() {
6346 PluginCommand::GetBufferText { request_id, .. } => request_id,
6347 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6348 };
6349
6350 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6352
6353 backend
6355 .plugin_contexts
6356 .borrow()
6357 .get("test")
6358 .unwrap()
6359 .clone()
6360 .with(|ctx| {
6361 run_pending_jobs_checked(&ctx, "test async getText");
6362 });
6363
6364 backend
6366 .plugin_contexts
6367 .borrow()
6368 .get("test")
6369 .unwrap()
6370 .clone()
6371 .with(|ctx| {
6372 let global = ctx.globals();
6373 let result: String = global.get("_resolvedText").unwrap();
6374 assert_eq!(result, "hello world");
6375 });
6376 }
6377
6378 #[test]
6379 fn test_plugin_translation() {
6380 let (mut backend, _rx) = create_test_backend();
6381
6382 backend
6384 .execute_js(
6385 r#"
6386 const editor = getEditor();
6387 globalThis._translated = editor.t("test.key");
6388 "#,
6389 "test.js",
6390 )
6391 .unwrap();
6392
6393 backend
6394 .plugin_contexts
6395 .borrow()
6396 .get("test")
6397 .unwrap()
6398 .clone()
6399 .with(|ctx| {
6400 let global = ctx.globals();
6401 let result: String = global.get("_translated").unwrap();
6403 assert_eq!(result, "test.key");
6404 });
6405 }
6406
6407 #[test]
6408 fn test_plugin_translation_with_registered_strings() {
6409 let (mut backend, _rx) = create_test_backend();
6410
6411 let mut en_strings = std::collections::HashMap::new();
6413 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6414 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6415
6416 let mut strings = std::collections::HashMap::new();
6417 strings.insert("en".to_string(), en_strings);
6418
6419 if let Some(bridge) = backend
6421 .services
6422 .as_any()
6423 .downcast_ref::<TestServiceBridge>()
6424 {
6425 let mut en = bridge.en_strings.lock().unwrap();
6426 en.insert("greeting".to_string(), "Hello, World!".to_string());
6427 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6428 }
6429
6430 backend
6432 .execute_js(
6433 r#"
6434 const editor = getEditor();
6435 globalThis._greeting = editor.t("greeting");
6436 globalThis._prompt = editor.t("prompt.find_file");
6437 globalThis._missing = editor.t("nonexistent.key");
6438 "#,
6439 "test.js",
6440 )
6441 .unwrap();
6442
6443 backend
6444 .plugin_contexts
6445 .borrow()
6446 .get("test")
6447 .unwrap()
6448 .clone()
6449 .with(|ctx| {
6450 let global = ctx.globals();
6451 let greeting: String = global.get("_greeting").unwrap();
6452 assert_eq!(greeting, "Hello, World!");
6453
6454 let prompt: String = global.get("_prompt").unwrap();
6455 assert_eq!(prompt, "Find file: ");
6456
6457 let missing: String = global.get("_missing").unwrap();
6459 assert_eq!(missing, "nonexistent.key");
6460 });
6461 }
6462
6463 #[test]
6466 fn test_api_set_line_indicator() {
6467 let (mut backend, rx) = create_test_backend();
6468
6469 backend
6470 .execute_js(
6471 r#"
6472 const editor = getEditor();
6473 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6474 "#,
6475 "test.js",
6476 )
6477 .unwrap();
6478
6479 let cmd = rx.try_recv().unwrap();
6480 match cmd {
6481 PluginCommand::SetLineIndicator {
6482 buffer_id,
6483 line,
6484 namespace,
6485 symbol,
6486 color,
6487 priority,
6488 } => {
6489 assert_eq!(buffer_id.0, 1);
6490 assert_eq!(line, 5);
6491 assert_eq!(namespace, "test-ns");
6492 assert_eq!(symbol, "●");
6493 assert_eq!(color, (255, 0, 0));
6494 assert_eq!(priority, 10);
6495 }
6496 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
6497 }
6498 }
6499
6500 #[test]
6501 fn test_api_clear_line_indicators() {
6502 let (mut backend, rx) = create_test_backend();
6503
6504 backend
6505 .execute_js(
6506 r#"
6507 const editor = getEditor();
6508 editor.clearLineIndicators(1, "test-ns");
6509 "#,
6510 "test.js",
6511 )
6512 .unwrap();
6513
6514 let cmd = rx.try_recv().unwrap();
6515 match cmd {
6516 PluginCommand::ClearLineIndicators {
6517 buffer_id,
6518 namespace,
6519 } => {
6520 assert_eq!(buffer_id.0, 1);
6521 assert_eq!(namespace, "test-ns");
6522 }
6523 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
6524 }
6525 }
6526
6527 #[test]
6530 fn test_api_create_virtual_buffer_sends_command() {
6531 let (mut backend, rx) = create_test_backend();
6532
6533 backend
6534 .execute_js(
6535 r#"
6536 const editor = getEditor();
6537 editor.createVirtualBuffer({
6538 name: "*Test Buffer*",
6539 mode: "test-mode",
6540 readOnly: true,
6541 entries: [
6542 { text: "Line 1\n", properties: { type: "header" } },
6543 { text: "Line 2\n", properties: { type: "content" } }
6544 ],
6545 showLineNumbers: false,
6546 showCursors: true,
6547 editingDisabled: true
6548 });
6549 "#,
6550 "test.js",
6551 )
6552 .unwrap();
6553
6554 let cmd = rx.try_recv().unwrap();
6555 match cmd {
6556 PluginCommand::CreateVirtualBufferWithContent {
6557 name,
6558 mode,
6559 read_only,
6560 entries,
6561 show_line_numbers,
6562 show_cursors,
6563 editing_disabled,
6564 ..
6565 } => {
6566 assert_eq!(name, "*Test Buffer*");
6567 assert_eq!(mode, "test-mode");
6568 assert!(read_only);
6569 assert_eq!(entries.len(), 2);
6570 assert_eq!(entries[0].text, "Line 1\n");
6571 assert!(!show_line_numbers);
6572 assert!(show_cursors);
6573 assert!(editing_disabled);
6574 }
6575 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
6576 }
6577 }
6578
6579 #[test]
6580 fn test_api_set_virtual_buffer_content() {
6581 let (mut backend, rx) = create_test_backend();
6582
6583 backend
6584 .execute_js(
6585 r#"
6586 const editor = getEditor();
6587 editor.setVirtualBufferContent(5, [
6588 { text: "New content\n", properties: { type: "updated" } }
6589 ]);
6590 "#,
6591 "test.js",
6592 )
6593 .unwrap();
6594
6595 let cmd = rx.try_recv().unwrap();
6596 match cmd {
6597 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
6598 assert_eq!(buffer_id.0, 5);
6599 assert_eq!(entries.len(), 1);
6600 assert_eq!(entries[0].text, "New content\n");
6601 }
6602 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
6603 }
6604 }
6605
6606 #[test]
6609 fn test_api_add_overlay() {
6610 let (mut backend, rx) = create_test_backend();
6611
6612 backend
6613 .execute_js(
6614 r#"
6615 const editor = getEditor();
6616 editor.addOverlay(1, "highlight", 10, 20, {
6617 fg: [255, 128, 0],
6618 bg: [50, 50, 50],
6619 bold: true,
6620 });
6621 "#,
6622 "test.js",
6623 )
6624 .unwrap();
6625
6626 let cmd = rx.try_recv().unwrap();
6627 match cmd {
6628 PluginCommand::AddOverlay {
6629 buffer_id,
6630 namespace,
6631 range,
6632 options,
6633 } => {
6634 use fresh_core::api::OverlayColorSpec;
6635 assert_eq!(buffer_id.0, 1);
6636 assert!(namespace.is_some());
6637 assert_eq!(namespace.unwrap().as_str(), "highlight");
6638 assert_eq!(range, 10..20);
6639 assert!(matches!(
6640 options.fg,
6641 Some(OverlayColorSpec::Rgb(255, 128, 0))
6642 ));
6643 assert!(matches!(
6644 options.bg,
6645 Some(OverlayColorSpec::Rgb(50, 50, 50))
6646 ));
6647 assert!(!options.underline);
6648 assert!(options.bold);
6649 assert!(!options.italic);
6650 assert!(!options.extend_to_line_end);
6651 }
6652 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6653 }
6654 }
6655
6656 #[test]
6657 fn test_api_add_overlay_with_theme_keys() {
6658 let (mut backend, rx) = create_test_backend();
6659
6660 backend
6661 .execute_js(
6662 r#"
6663 const editor = getEditor();
6664 // Test with theme keys for colors
6665 editor.addOverlay(1, "themed", 0, 10, {
6666 fg: "ui.status_bar_fg",
6667 bg: "editor.selection_bg",
6668 });
6669 "#,
6670 "test.js",
6671 )
6672 .unwrap();
6673
6674 let cmd = rx.try_recv().unwrap();
6675 match cmd {
6676 PluginCommand::AddOverlay {
6677 buffer_id,
6678 namespace,
6679 range,
6680 options,
6681 } => {
6682 use fresh_core::api::OverlayColorSpec;
6683 assert_eq!(buffer_id.0, 1);
6684 assert!(namespace.is_some());
6685 assert_eq!(namespace.unwrap().as_str(), "themed");
6686 assert_eq!(range, 0..10);
6687 assert!(matches!(
6688 &options.fg,
6689 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
6690 ));
6691 assert!(matches!(
6692 &options.bg,
6693 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
6694 ));
6695 assert!(!options.underline);
6696 assert!(!options.bold);
6697 assert!(!options.italic);
6698 assert!(!options.extend_to_line_end);
6699 }
6700 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6701 }
6702 }
6703
6704 #[test]
6705 fn test_api_clear_namespace() {
6706 let (mut backend, rx) = create_test_backend();
6707
6708 backend
6709 .execute_js(
6710 r#"
6711 const editor = getEditor();
6712 editor.clearNamespace(1, "highlight");
6713 "#,
6714 "test.js",
6715 )
6716 .unwrap();
6717
6718 let cmd = rx.try_recv().unwrap();
6719 match cmd {
6720 PluginCommand::ClearNamespace {
6721 buffer_id,
6722 namespace,
6723 } => {
6724 assert_eq!(buffer_id.0, 1);
6725 assert_eq!(namespace.as_str(), "highlight");
6726 }
6727 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
6728 }
6729 }
6730
6731 #[test]
6734 fn test_api_get_theme_schema() {
6735 let (mut backend, _rx) = create_test_backend();
6736
6737 backend
6738 .execute_js(
6739 r#"
6740 const editor = getEditor();
6741 const schema = editor.getThemeSchema();
6742 globalThis._isObject = typeof schema === 'object' && schema !== null;
6743 "#,
6744 "test.js",
6745 )
6746 .unwrap();
6747
6748 backend
6749 .plugin_contexts
6750 .borrow()
6751 .get("test")
6752 .unwrap()
6753 .clone()
6754 .with(|ctx| {
6755 let global = ctx.globals();
6756 let is_object: bool = global.get("_isObject").unwrap();
6757 assert!(is_object);
6759 });
6760 }
6761
6762 #[test]
6763 fn test_api_get_builtin_themes() {
6764 let (mut backend, _rx) = create_test_backend();
6765
6766 backend
6767 .execute_js(
6768 r#"
6769 const editor = getEditor();
6770 const themes = editor.getBuiltinThemes();
6771 globalThis._isObject = typeof themes === 'object' && themes !== null;
6772 "#,
6773 "test.js",
6774 )
6775 .unwrap();
6776
6777 backend
6778 .plugin_contexts
6779 .borrow()
6780 .get("test")
6781 .unwrap()
6782 .clone()
6783 .with(|ctx| {
6784 let global = ctx.globals();
6785 let is_object: bool = global.get("_isObject").unwrap();
6786 assert!(is_object);
6788 });
6789 }
6790
6791 #[test]
6792 fn test_api_apply_theme() {
6793 let (mut backend, rx) = create_test_backend();
6794
6795 backend
6796 .execute_js(
6797 r#"
6798 const editor = getEditor();
6799 editor.applyTheme("dark");
6800 "#,
6801 "test.js",
6802 )
6803 .unwrap();
6804
6805 let cmd = rx.try_recv().unwrap();
6806 match cmd {
6807 PluginCommand::ApplyTheme { theme_name } => {
6808 assert_eq!(theme_name, "dark");
6809 }
6810 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
6811 }
6812 }
6813
6814 #[test]
6815 fn test_api_get_theme_data_missing() {
6816 let (mut backend, _rx) = create_test_backend();
6817
6818 backend
6819 .execute_js(
6820 r#"
6821 const editor = getEditor();
6822 const data = editor.getThemeData("nonexistent");
6823 globalThis._isNull = data === null;
6824 "#,
6825 "test.js",
6826 )
6827 .unwrap();
6828
6829 backend
6830 .plugin_contexts
6831 .borrow()
6832 .get("test")
6833 .unwrap()
6834 .clone()
6835 .with(|ctx| {
6836 let global = ctx.globals();
6837 let is_null: bool = global.get("_isNull").unwrap();
6838 assert!(is_null);
6840 });
6841 }
6842
6843 #[test]
6844 fn test_api_get_theme_data_present() {
6845 let (tx, _rx) = mpsc::channel();
6847 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6848 let services = Arc::new(ThemeCacheTestBridge {
6849 inner: TestServiceBridge::new(),
6850 });
6851 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6852
6853 backend
6854 .execute_js(
6855 r#"
6856 const editor = getEditor();
6857 const data = editor.getThemeData("test-theme");
6858 globalThis._hasData = data !== null && typeof data === 'object';
6859 globalThis._name = data ? data.name : null;
6860 "#,
6861 "test.js",
6862 )
6863 .unwrap();
6864
6865 backend
6866 .plugin_contexts
6867 .borrow()
6868 .get("test")
6869 .unwrap()
6870 .clone()
6871 .with(|ctx| {
6872 let global = ctx.globals();
6873 let has_data: bool = global.get("_hasData").unwrap();
6874 assert!(has_data, "getThemeData should return theme object");
6875 let name: String = global.get("_name").unwrap();
6876 assert_eq!(name, "test-theme");
6877 });
6878 }
6879
6880 #[test]
6881 fn test_api_theme_file_exists() {
6882 let (mut backend, _rx) = create_test_backend();
6883
6884 backend
6885 .execute_js(
6886 r#"
6887 const editor = getEditor();
6888 globalThis._exists = editor.themeFileExists("anything");
6889 "#,
6890 "test.js",
6891 )
6892 .unwrap();
6893
6894 backend
6895 .plugin_contexts
6896 .borrow()
6897 .get("test")
6898 .unwrap()
6899 .clone()
6900 .with(|ctx| {
6901 let global = ctx.globals();
6902 let exists: bool = global.get("_exists").unwrap();
6903 assert!(!exists);
6905 });
6906 }
6907
6908 #[test]
6909 fn test_api_save_theme_file_error() {
6910 let (mut backend, _rx) = create_test_backend();
6911
6912 backend
6913 .execute_js(
6914 r#"
6915 const editor = getEditor();
6916 let threw = false;
6917 try {
6918 editor.saveThemeFile("test", "{}");
6919 } catch (e) {
6920 threw = true;
6921 }
6922 globalThis._threw = threw;
6923 "#,
6924 "test.js",
6925 )
6926 .unwrap();
6927
6928 backend
6929 .plugin_contexts
6930 .borrow()
6931 .get("test")
6932 .unwrap()
6933 .clone()
6934 .with(|ctx| {
6935 let global = ctx.globals();
6936 let threw: bool = global.get("_threw").unwrap();
6937 assert!(threw);
6939 });
6940 }
6941
6942 struct ThemeCacheTestBridge {
6944 inner: TestServiceBridge,
6945 }
6946
6947 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6948 fn as_any(&self) -> &dyn std::any::Any {
6949 self
6950 }
6951 fn translate(
6952 &self,
6953 plugin_name: &str,
6954 key: &str,
6955 args: &HashMap<String, String>,
6956 ) -> String {
6957 self.inner.translate(plugin_name, key, args)
6958 }
6959 fn current_locale(&self) -> String {
6960 self.inner.current_locale()
6961 }
6962 fn set_js_execution_state(&self, state: String) {
6963 self.inner.set_js_execution_state(state);
6964 }
6965 fn clear_js_execution_state(&self) {
6966 self.inner.clear_js_execution_state();
6967 }
6968 fn get_theme_schema(&self) -> serde_json::Value {
6969 self.inner.get_theme_schema()
6970 }
6971 fn get_builtin_themes(&self) -> serde_json::Value {
6972 self.inner.get_builtin_themes()
6973 }
6974 fn register_command(&self, command: fresh_core::command::Command) {
6975 self.inner.register_command(command);
6976 }
6977 fn unregister_command(&self, name: &str) {
6978 self.inner.unregister_command(name);
6979 }
6980 fn unregister_commands_by_prefix(&self, prefix: &str) {
6981 self.inner.unregister_commands_by_prefix(prefix);
6982 }
6983 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6984 self.inner.unregister_commands_by_plugin(plugin_name);
6985 }
6986 fn plugins_dir(&self) -> std::path::PathBuf {
6987 self.inner.plugins_dir()
6988 }
6989 fn config_dir(&self) -> std::path::PathBuf {
6990 self.inner.config_dir()
6991 }
6992 fn data_dir(&self) -> std::path::PathBuf {
6993 self.inner.data_dir()
6994 }
6995 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6996 if name == "test-theme" {
6997 Some(serde_json::json!({
6998 "name": "test-theme",
6999 "editor": {},
7000 "ui": {},
7001 "syntax": {}
7002 }))
7003 } else {
7004 None
7005 }
7006 }
7007 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7008 Err("test bridge does not support save".to_string())
7009 }
7010 fn theme_file_exists(&self, name: &str) -> bool {
7011 name == "test-theme"
7012 }
7013 }
7014
7015 #[test]
7018 fn test_api_close_buffer() {
7019 let (mut backend, rx) = create_test_backend();
7020
7021 backend
7022 .execute_js(
7023 r#"
7024 const editor = getEditor();
7025 editor.closeBuffer(3);
7026 "#,
7027 "test.js",
7028 )
7029 .unwrap();
7030
7031 let cmd = rx.try_recv().unwrap();
7032 match cmd {
7033 PluginCommand::CloseBuffer { buffer_id } => {
7034 assert_eq!(buffer_id.0, 3);
7035 }
7036 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
7037 }
7038 }
7039
7040 #[test]
7041 fn test_api_focus_split() {
7042 let (mut backend, rx) = create_test_backend();
7043
7044 backend
7045 .execute_js(
7046 r#"
7047 const editor = getEditor();
7048 editor.focusSplit(2);
7049 "#,
7050 "test.js",
7051 )
7052 .unwrap();
7053
7054 let cmd = rx.try_recv().unwrap();
7055 match cmd {
7056 PluginCommand::FocusSplit { split_id } => {
7057 assert_eq!(split_id.0, 2);
7058 }
7059 _ => panic!("Expected FocusSplit, got {:?}", cmd),
7060 }
7061 }
7062
7063 #[test]
7064 fn test_api_list_buffers() {
7065 let (tx, _rx) = mpsc::channel();
7066 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7067
7068 {
7070 let mut state = state_snapshot.write().unwrap();
7071 state.buffers.insert(
7072 BufferId(0),
7073 BufferInfo {
7074 id: BufferId(0),
7075 path: Some(PathBuf::from("/test1.txt")),
7076 modified: false,
7077 length: 100,
7078 is_virtual: false,
7079 view_mode: "source".to_string(),
7080 is_composing_in_any_split: false,
7081 compose_width: None,
7082 language: "text".to_string(),
7083 is_preview: false,
7084 },
7085 );
7086 state.buffers.insert(
7087 BufferId(1),
7088 BufferInfo {
7089 id: BufferId(1),
7090 path: Some(PathBuf::from("/test2.txt")),
7091 modified: true,
7092 length: 200,
7093 is_virtual: false,
7094 view_mode: "source".to_string(),
7095 is_composing_in_any_split: false,
7096 compose_width: None,
7097 language: "text".to_string(),
7098 is_preview: false,
7099 },
7100 );
7101 }
7102
7103 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7104 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7105
7106 backend
7107 .execute_js(
7108 r#"
7109 const editor = getEditor();
7110 const buffers = editor.listBuffers();
7111 globalThis._isArray = Array.isArray(buffers);
7112 globalThis._length = buffers.length;
7113 "#,
7114 "test.js",
7115 )
7116 .unwrap();
7117
7118 backend
7119 .plugin_contexts
7120 .borrow()
7121 .get("test")
7122 .unwrap()
7123 .clone()
7124 .with(|ctx| {
7125 let global = ctx.globals();
7126 let is_array: bool = global.get("_isArray").unwrap();
7127 let length: u32 = global.get("_length").unwrap();
7128 assert!(is_array);
7129 assert_eq!(length, 2);
7130 });
7131 }
7132
7133 #[test]
7136 fn test_api_start_prompt() {
7137 let (mut backend, rx) = create_test_backend();
7138
7139 backend
7140 .execute_js(
7141 r#"
7142 const editor = getEditor();
7143 editor.startPrompt("Enter value:", "test-prompt");
7144 "#,
7145 "test.js",
7146 )
7147 .unwrap();
7148
7149 let cmd = rx.try_recv().unwrap();
7150 match cmd {
7151 PluginCommand::StartPrompt { label, prompt_type } => {
7152 assert_eq!(label, "Enter value:");
7153 assert_eq!(prompt_type, "test-prompt");
7154 }
7155 _ => panic!("Expected StartPrompt, got {:?}", cmd),
7156 }
7157 }
7158
7159 #[test]
7160 fn test_api_start_prompt_with_initial() {
7161 let (mut backend, rx) = create_test_backend();
7162
7163 backend
7164 .execute_js(
7165 r#"
7166 const editor = getEditor();
7167 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
7168 "#,
7169 "test.js",
7170 )
7171 .unwrap();
7172
7173 let cmd = rx.try_recv().unwrap();
7174 match cmd {
7175 PluginCommand::StartPromptWithInitial {
7176 label,
7177 prompt_type,
7178 initial_value,
7179 } => {
7180 assert_eq!(label, "Enter value:");
7181 assert_eq!(prompt_type, "test-prompt");
7182 assert_eq!(initial_value, "default");
7183 }
7184 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
7185 }
7186 }
7187
7188 #[test]
7189 fn test_api_set_prompt_suggestions() {
7190 let (mut backend, rx) = create_test_backend();
7191
7192 backend
7193 .execute_js(
7194 r#"
7195 const editor = getEditor();
7196 editor.setPromptSuggestions([
7197 { text: "Option 1", value: "opt1" },
7198 { text: "Option 2", value: "opt2" }
7199 ]);
7200 "#,
7201 "test.js",
7202 )
7203 .unwrap();
7204
7205 let cmd = rx.try_recv().unwrap();
7206 match cmd {
7207 PluginCommand::SetPromptSuggestions { suggestions } => {
7208 assert_eq!(suggestions.len(), 2);
7209 assert_eq!(suggestions[0].text, "Option 1");
7210 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
7211 }
7212 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
7213 }
7214 }
7215
7216 #[test]
7219 fn test_api_get_active_buffer_id() {
7220 let (tx, _rx) = mpsc::channel();
7221 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7222
7223 {
7224 let mut state = state_snapshot.write().unwrap();
7225 state.active_buffer_id = BufferId(42);
7226 }
7227
7228 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7229 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7230
7231 backend
7232 .execute_js(
7233 r#"
7234 const editor = getEditor();
7235 globalThis._activeId = editor.getActiveBufferId();
7236 "#,
7237 "test.js",
7238 )
7239 .unwrap();
7240
7241 backend
7242 .plugin_contexts
7243 .borrow()
7244 .get("test")
7245 .unwrap()
7246 .clone()
7247 .with(|ctx| {
7248 let global = ctx.globals();
7249 let result: u32 = global.get("_activeId").unwrap();
7250 assert_eq!(result, 42);
7251 });
7252 }
7253
7254 #[test]
7255 fn test_api_get_active_split_id() {
7256 let (tx, _rx) = mpsc::channel();
7257 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7258
7259 {
7260 let mut state = state_snapshot.write().unwrap();
7261 state.active_split_id = 7;
7262 }
7263
7264 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7265 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7266
7267 backend
7268 .execute_js(
7269 r#"
7270 const editor = getEditor();
7271 globalThis._splitId = editor.getActiveSplitId();
7272 "#,
7273 "test.js",
7274 )
7275 .unwrap();
7276
7277 backend
7278 .plugin_contexts
7279 .borrow()
7280 .get("test")
7281 .unwrap()
7282 .clone()
7283 .with(|ctx| {
7284 let global = ctx.globals();
7285 let result: u32 = global.get("_splitId").unwrap();
7286 assert_eq!(result, 7);
7287 });
7288 }
7289
7290 #[test]
7293 fn test_api_file_exists() {
7294 let (mut backend, _rx) = create_test_backend();
7295
7296 backend
7297 .execute_js(
7298 r#"
7299 const editor = getEditor();
7300 // Test with a path that definitely exists
7301 globalThis._exists = editor.fileExists("/");
7302 "#,
7303 "test.js",
7304 )
7305 .unwrap();
7306
7307 backend
7308 .plugin_contexts
7309 .borrow()
7310 .get("test")
7311 .unwrap()
7312 .clone()
7313 .with(|ctx| {
7314 let global = ctx.globals();
7315 let result: bool = global.get("_exists").unwrap();
7316 assert!(result);
7317 });
7318 }
7319
7320 #[test]
7321 fn test_api_get_cwd() {
7322 let (mut backend, _rx) = create_test_backend();
7323
7324 backend
7325 .execute_js(
7326 r#"
7327 const editor = getEditor();
7328 globalThis._cwd = editor.getCwd();
7329 "#,
7330 "test.js",
7331 )
7332 .unwrap();
7333
7334 backend
7335 .plugin_contexts
7336 .borrow()
7337 .get("test")
7338 .unwrap()
7339 .clone()
7340 .with(|ctx| {
7341 let global = ctx.globals();
7342 let result: String = global.get("_cwd").unwrap();
7343 assert!(!result.is_empty());
7345 });
7346 }
7347
7348 #[test]
7349 fn test_api_get_env() {
7350 let (mut backend, _rx) = create_test_backend();
7351
7352 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
7354
7355 backend
7356 .execute_js(
7357 r#"
7358 const editor = getEditor();
7359 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
7360 "#,
7361 "test.js",
7362 )
7363 .unwrap();
7364
7365 backend
7366 .plugin_contexts
7367 .borrow()
7368 .get("test")
7369 .unwrap()
7370 .clone()
7371 .with(|ctx| {
7372 let global = ctx.globals();
7373 let result: Option<String> = global.get("_envVal").unwrap();
7374 assert_eq!(result, Some("test_value".to_string()));
7375 });
7376
7377 std::env::remove_var("TEST_PLUGIN_VAR");
7378 }
7379
7380 #[test]
7381 fn test_api_get_config() {
7382 let (mut backend, _rx) = create_test_backend();
7383
7384 backend
7385 .execute_js(
7386 r#"
7387 const editor = getEditor();
7388 const config = editor.getConfig();
7389 globalThis._isObject = typeof config === 'object';
7390 "#,
7391 "test.js",
7392 )
7393 .unwrap();
7394
7395 backend
7396 .plugin_contexts
7397 .borrow()
7398 .get("test")
7399 .unwrap()
7400 .clone()
7401 .with(|ctx| {
7402 let global = ctx.globals();
7403 let is_object: bool = global.get("_isObject").unwrap();
7404 assert!(is_object);
7406 });
7407 }
7408
7409 #[test]
7410 fn test_api_get_themes_dir() {
7411 let (mut backend, _rx) = create_test_backend();
7412
7413 backend
7414 .execute_js(
7415 r#"
7416 const editor = getEditor();
7417 globalThis._themesDir = editor.getThemesDir();
7418 "#,
7419 "test.js",
7420 )
7421 .unwrap();
7422
7423 backend
7424 .plugin_contexts
7425 .borrow()
7426 .get("test")
7427 .unwrap()
7428 .clone()
7429 .with(|ctx| {
7430 let global = ctx.globals();
7431 let result: String = global.get("_themesDir").unwrap();
7432 assert!(!result.is_empty());
7434 });
7435 }
7436
7437 #[test]
7440 fn test_api_read_dir() {
7441 let (mut backend, _rx) = create_test_backend();
7442
7443 backend
7444 .execute_js(
7445 r#"
7446 const editor = getEditor();
7447 const entries = editor.readDir("/tmp");
7448 globalThis._isArray = Array.isArray(entries);
7449 globalThis._length = entries.length;
7450 "#,
7451 "test.js",
7452 )
7453 .unwrap();
7454
7455 backend
7456 .plugin_contexts
7457 .borrow()
7458 .get("test")
7459 .unwrap()
7460 .clone()
7461 .with(|ctx| {
7462 let global = ctx.globals();
7463 let is_array: bool = global.get("_isArray").unwrap();
7464 let length: u32 = global.get("_length").unwrap();
7465 assert!(is_array);
7467 let _ = length;
7469 });
7470 }
7471
7472 #[test]
7475 fn test_api_execute_action() {
7476 let (mut backend, rx) = create_test_backend();
7477
7478 backend
7479 .execute_js(
7480 r#"
7481 const editor = getEditor();
7482 editor.executeAction("move_cursor_up");
7483 "#,
7484 "test.js",
7485 )
7486 .unwrap();
7487
7488 let cmd = rx.try_recv().unwrap();
7489 match cmd {
7490 PluginCommand::ExecuteAction { action_name } => {
7491 assert_eq!(action_name, "move_cursor_up");
7492 }
7493 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
7494 }
7495 }
7496
7497 #[test]
7500 fn test_api_debug() {
7501 let (mut backend, _rx) = create_test_backend();
7502
7503 backend
7505 .execute_js(
7506 r#"
7507 const editor = getEditor();
7508 editor.debug("Test debug message");
7509 editor.debug("Another message with special chars: <>&\"'");
7510 "#,
7511 "test.js",
7512 )
7513 .unwrap();
7514 }
7516
7517 #[test]
7520 fn test_typescript_preamble_generated() {
7521 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
7523 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
7524 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
7525 println!(
7526 "Generated {} bytes of TypeScript preamble",
7527 JSEDITORAPI_TS_PREAMBLE.len()
7528 );
7529 }
7530
7531 #[test]
7532 fn test_typescript_editor_api_generated() {
7533 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
7535 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
7536 println!(
7537 "Generated {} bytes of EditorAPI interface",
7538 JSEDITORAPI_TS_EDITOR_API.len()
7539 );
7540 }
7541
7542 #[test]
7543 fn test_js_methods_list() {
7544 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
7546 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
7547 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
7549 if i < 20 {
7550 println!(" - {}", method);
7551 }
7552 }
7553 if JSEDITORAPI_JS_METHODS.len() > 20 {
7554 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
7555 }
7556 }
7557
7558 #[test]
7561 fn test_api_load_plugin_sends_command() {
7562 let (mut backend, rx) = create_test_backend();
7563
7564 backend
7566 .execute_js(
7567 r#"
7568 const editor = getEditor();
7569 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
7570 "#,
7571 "test.js",
7572 )
7573 .unwrap();
7574
7575 let cmd = rx.try_recv().unwrap();
7577 match cmd {
7578 PluginCommand::LoadPlugin { path, callback_id } => {
7579 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
7580 assert!(callback_id.0 > 0); }
7582 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
7583 }
7584 }
7585
7586 #[test]
7587 fn test_api_unload_plugin_sends_command() {
7588 let (mut backend, rx) = create_test_backend();
7589
7590 backend
7592 .execute_js(
7593 r#"
7594 const editor = getEditor();
7595 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
7596 "#,
7597 "test.js",
7598 )
7599 .unwrap();
7600
7601 let cmd = rx.try_recv().unwrap();
7603 match cmd {
7604 PluginCommand::UnloadPlugin { name, callback_id } => {
7605 assert_eq!(name, "my-plugin");
7606 assert!(callback_id.0 > 0); }
7608 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
7609 }
7610 }
7611
7612 #[test]
7613 fn test_api_reload_plugin_sends_command() {
7614 let (mut backend, rx) = create_test_backend();
7615
7616 backend
7618 .execute_js(
7619 r#"
7620 const editor = getEditor();
7621 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
7622 "#,
7623 "test.js",
7624 )
7625 .unwrap();
7626
7627 let cmd = rx.try_recv().unwrap();
7629 match cmd {
7630 PluginCommand::ReloadPlugin { name, callback_id } => {
7631 assert_eq!(name, "my-plugin");
7632 assert!(callback_id.0 > 0); }
7634 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
7635 }
7636 }
7637
7638 #[test]
7639 fn test_api_load_plugin_resolves_callback() {
7640 let (mut backend, rx) = create_test_backend();
7641
7642 backend
7644 .execute_js(
7645 r#"
7646 const editor = getEditor();
7647 globalThis._loadResult = null;
7648 editor.loadPlugin("/path/to/plugin.ts").then(result => {
7649 globalThis._loadResult = result;
7650 });
7651 "#,
7652 "test.js",
7653 )
7654 .unwrap();
7655
7656 let callback_id = match rx.try_recv().unwrap() {
7658 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
7659 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
7660 };
7661
7662 backend.resolve_callback(callback_id, "true");
7664
7665 backend
7667 .plugin_contexts
7668 .borrow()
7669 .get("test")
7670 .unwrap()
7671 .clone()
7672 .with(|ctx| {
7673 run_pending_jobs_checked(&ctx, "test async loadPlugin");
7674 });
7675
7676 backend
7678 .plugin_contexts
7679 .borrow()
7680 .get("test")
7681 .unwrap()
7682 .clone()
7683 .with(|ctx| {
7684 let global = ctx.globals();
7685 let result: bool = global.get("_loadResult").unwrap();
7686 assert!(result);
7687 });
7688 }
7689
7690 #[test]
7691 fn test_api_version() {
7692 let (mut backend, _rx) = create_test_backend();
7693
7694 backend
7695 .execute_js(
7696 r#"
7697 const editor = getEditor();
7698 globalThis._apiVersion = editor.apiVersion();
7699 "#,
7700 "test.js",
7701 )
7702 .unwrap();
7703
7704 backend
7705 .plugin_contexts
7706 .borrow()
7707 .get("test")
7708 .unwrap()
7709 .clone()
7710 .with(|ctx| {
7711 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
7712 assert_eq!(version, 2);
7713 });
7714 }
7715
7716 #[test]
7717 fn test_api_unload_plugin_rejects_on_error() {
7718 let (mut backend, rx) = create_test_backend();
7719
7720 backend
7722 .execute_js(
7723 r#"
7724 const editor = getEditor();
7725 globalThis._unloadError = null;
7726 editor.unloadPlugin("nonexistent-plugin").catch(err => {
7727 globalThis._unloadError = err.message || String(err);
7728 });
7729 "#,
7730 "test.js",
7731 )
7732 .unwrap();
7733
7734 let callback_id = match rx.try_recv().unwrap() {
7736 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
7737 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
7738 };
7739
7740 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
7742
7743 backend
7745 .plugin_contexts
7746 .borrow()
7747 .get("test")
7748 .unwrap()
7749 .clone()
7750 .with(|ctx| {
7751 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
7752 });
7753
7754 backend
7756 .plugin_contexts
7757 .borrow()
7758 .get("test")
7759 .unwrap()
7760 .clone()
7761 .with(|ctx| {
7762 let global = ctx.globals();
7763 let error: String = global.get("_unloadError").unwrap();
7764 assert!(error.contains("nonexistent-plugin"));
7765 });
7766 }
7767
7768 #[test]
7769 fn test_api_set_global_state() {
7770 let (mut backend, rx) = create_test_backend();
7771
7772 backend
7773 .execute_js(
7774 r#"
7775 const editor = getEditor();
7776 editor.setGlobalState("myKey", { enabled: true, count: 42 });
7777 "#,
7778 "test_plugin.js",
7779 )
7780 .unwrap();
7781
7782 let cmd = rx.try_recv().unwrap();
7783 match cmd {
7784 PluginCommand::SetGlobalState {
7785 plugin_name,
7786 key,
7787 value,
7788 } => {
7789 assert_eq!(plugin_name, "test_plugin");
7790 assert_eq!(key, "myKey");
7791 let v = value.unwrap();
7792 assert_eq!(v["enabled"], serde_json::json!(true));
7793 assert_eq!(v["count"], serde_json::json!(42));
7794 }
7795 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7796 }
7797 }
7798
7799 #[test]
7800 fn test_api_set_global_state_delete() {
7801 let (mut backend, rx) = create_test_backend();
7802
7803 backend
7804 .execute_js(
7805 r#"
7806 const editor = getEditor();
7807 editor.setGlobalState("myKey", null);
7808 "#,
7809 "test_plugin.js",
7810 )
7811 .unwrap();
7812
7813 let cmd = rx.try_recv().unwrap();
7814 match cmd {
7815 PluginCommand::SetGlobalState {
7816 plugin_name,
7817 key,
7818 value,
7819 } => {
7820 assert_eq!(plugin_name, "test_plugin");
7821 assert_eq!(key, "myKey");
7822 assert!(value.is_none(), "null should delete the key");
7823 }
7824 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7825 }
7826 }
7827
7828 #[test]
7829 fn test_api_get_global_state_roundtrip() {
7830 let (mut backend, _rx) = create_test_backend();
7831
7832 backend
7834 .execute_js(
7835 r#"
7836 const editor = getEditor();
7837 editor.setGlobalState("flag", true);
7838 globalThis._result = editor.getGlobalState("flag");
7839 "#,
7840 "test_plugin.js",
7841 )
7842 .unwrap();
7843
7844 backend
7845 .plugin_contexts
7846 .borrow()
7847 .get("test_plugin")
7848 .unwrap()
7849 .clone()
7850 .with(|ctx| {
7851 let global = ctx.globals();
7852 let result: bool = global.get("_result").unwrap();
7853 assert!(
7854 result,
7855 "getGlobalState should return the value set by setGlobalState"
7856 );
7857 });
7858 }
7859
7860 #[test]
7861 fn test_api_get_global_state_missing_key() {
7862 let (mut backend, _rx) = create_test_backend();
7863
7864 backend
7865 .execute_js(
7866 r#"
7867 const editor = getEditor();
7868 globalThis._result = editor.getGlobalState("nonexistent");
7869 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
7870 "#,
7871 "test_plugin.js",
7872 )
7873 .unwrap();
7874
7875 backend
7876 .plugin_contexts
7877 .borrow()
7878 .get("test_plugin")
7879 .unwrap()
7880 .clone()
7881 .with(|ctx| {
7882 let global = ctx.globals();
7883 let is_undefined: bool = global.get("_isUndefined").unwrap();
7884 assert!(
7885 is_undefined,
7886 "getGlobalState for missing key should return undefined"
7887 );
7888 });
7889 }
7890
7891 #[test]
7892 fn test_api_global_state_isolation_between_plugins() {
7893 let (tx, _rx) = mpsc::channel();
7895 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7896 let services = Arc::new(TestServiceBridge::new());
7897
7898 let mut backend_a =
7900 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7901 .unwrap();
7902 backend_a
7903 .execute_js(
7904 r#"
7905 const editor = getEditor();
7906 editor.setGlobalState("flag", "from_plugin_a");
7907 "#,
7908 "plugin_a.js",
7909 )
7910 .unwrap();
7911
7912 let mut backend_b =
7914 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7915 .unwrap();
7916 backend_b
7917 .execute_js(
7918 r#"
7919 const editor = getEditor();
7920 editor.setGlobalState("flag", "from_plugin_b");
7921 "#,
7922 "plugin_b.js",
7923 )
7924 .unwrap();
7925
7926 backend_a
7928 .execute_js(
7929 r#"
7930 const editor = getEditor();
7931 globalThis._aValue = editor.getGlobalState("flag");
7932 "#,
7933 "plugin_a.js",
7934 )
7935 .unwrap();
7936
7937 backend_a
7938 .plugin_contexts
7939 .borrow()
7940 .get("plugin_a")
7941 .unwrap()
7942 .clone()
7943 .with(|ctx| {
7944 let global = ctx.globals();
7945 let a_value: String = global.get("_aValue").unwrap();
7946 assert_eq!(
7947 a_value, "from_plugin_a",
7948 "Plugin A should see its own value, not plugin B's"
7949 );
7950 });
7951
7952 backend_b
7954 .execute_js(
7955 r#"
7956 const editor = getEditor();
7957 globalThis._bValue = editor.getGlobalState("flag");
7958 "#,
7959 "plugin_b.js",
7960 )
7961 .unwrap();
7962
7963 backend_b
7964 .plugin_contexts
7965 .borrow()
7966 .get("plugin_b")
7967 .unwrap()
7968 .clone()
7969 .with(|ctx| {
7970 let global = ctx.globals();
7971 let b_value: String = global.get("_bValue").unwrap();
7972 assert_eq!(
7973 b_value, "from_plugin_b",
7974 "Plugin B should see its own value, not plugin A's"
7975 );
7976 });
7977 }
7978
7979 #[test]
7980 fn test_register_command_collision_different_plugins() {
7981 let (mut backend, _rx) = create_test_backend();
7982
7983 backend
7985 .execute_js(
7986 r#"
7987 const editor = getEditor();
7988 globalThis.handlerA = function() { };
7989 editor.registerCommand("My Command", "From A", "handlerA", null);
7990 "#,
7991 "plugin_a.js",
7992 )
7993 .unwrap();
7994
7995 let result = backend.execute_js(
7997 r#"
7998 const editor = getEditor();
7999 globalThis.handlerB = function() { };
8000 editor.registerCommand("My Command", "From B", "handlerB", null);
8001 "#,
8002 "plugin_b.js",
8003 );
8004
8005 assert!(
8006 result.is_err(),
8007 "Second plugin registering the same command name should fail"
8008 );
8009 let err_msg = result.unwrap_err().to_string();
8010 assert!(
8011 err_msg.contains("already registered"),
8012 "Error should mention collision: {}",
8013 err_msg
8014 );
8015 }
8016
8017 #[test]
8018 fn test_register_command_same_plugin_allowed() {
8019 let (mut backend, _rx) = create_test_backend();
8020
8021 backend
8023 .execute_js(
8024 r#"
8025 const editor = getEditor();
8026 globalThis.handler1 = function() { };
8027 editor.registerCommand("My Command", "Version 1", "handler1", null);
8028 globalThis.handler2 = function() { };
8029 editor.registerCommand("My Command", "Version 2", "handler2", null);
8030 "#,
8031 "plugin_a.js",
8032 )
8033 .unwrap();
8034 }
8035
8036 #[test]
8037 fn test_register_command_after_unregister() {
8038 let (mut backend, _rx) = create_test_backend();
8039
8040 backend
8042 .execute_js(
8043 r#"
8044 const editor = getEditor();
8045 globalThis.handlerA = function() { };
8046 editor.registerCommand("My Command", "From A", "handlerA", null);
8047 editor.unregisterCommand("My Command");
8048 "#,
8049 "plugin_a.js",
8050 )
8051 .unwrap();
8052
8053 backend
8055 .execute_js(
8056 r#"
8057 const editor = getEditor();
8058 globalThis.handlerB = function() { };
8059 editor.registerCommand("My Command", "From B", "handlerB", null);
8060 "#,
8061 "plugin_b.js",
8062 )
8063 .unwrap();
8064 }
8065
8066 #[test]
8067 fn test_register_command_collision_caught_in_try_catch() {
8068 let (mut backend, _rx) = create_test_backend();
8069
8070 backend
8072 .execute_js(
8073 r#"
8074 const editor = getEditor();
8075 globalThis.handlerA = function() { };
8076 editor.registerCommand("My Command", "From A", "handlerA", null);
8077 "#,
8078 "plugin_a.js",
8079 )
8080 .unwrap();
8081
8082 backend
8084 .execute_js(
8085 r#"
8086 const editor = getEditor();
8087 globalThis.handlerB = function() { };
8088 let caught = false;
8089 try {
8090 editor.registerCommand("My Command", "From B", "handlerB", null);
8091 } catch (e) {
8092 caught = true;
8093 }
8094 if (!caught) throw new Error("Expected collision error");
8095 "#,
8096 "plugin_b.js",
8097 )
8098 .unwrap();
8099 }
8100
8101 #[test]
8102 fn test_register_command_i18n_key_no_collision_across_plugins() {
8103 let (mut backend, _rx) = create_test_backend();
8104
8105 backend
8107 .execute_js(
8108 r#"
8109 const editor = getEditor();
8110 globalThis.handlerA = function() { };
8111 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
8112 "#,
8113 "plugin_a.js",
8114 )
8115 .unwrap();
8116
8117 backend
8120 .execute_js(
8121 r#"
8122 const editor = getEditor();
8123 globalThis.handlerB = function() { };
8124 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
8125 "#,
8126 "plugin_b.js",
8127 )
8128 .unwrap();
8129 }
8130
8131 #[test]
8132 fn test_register_command_non_i18n_still_collides() {
8133 let (mut backend, _rx) = create_test_backend();
8134
8135 backend
8137 .execute_js(
8138 r#"
8139 const editor = getEditor();
8140 globalThis.handlerA = function() { };
8141 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
8142 "#,
8143 "plugin_a.js",
8144 )
8145 .unwrap();
8146
8147 let result = backend.execute_js(
8149 r#"
8150 const editor = getEditor();
8151 globalThis.handlerB = function() { };
8152 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
8153 "#,
8154 "plugin_b.js",
8155 );
8156
8157 assert!(
8158 result.is_err(),
8159 "Non-%-prefixed names should still collide across plugins"
8160 );
8161 }
8162}