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>> {
1644 let config: serde_json::Value = self
1645 .state_snapshot
1646 .read()
1647 .map(|s| s.config.clone())
1648 .unwrap_or_else(|_| serde_json::json!({}));
1649
1650 rquickjs_serde::to_value(ctx, &config)
1651 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1652 }
1653
1654 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1656 let config: serde_json::Value = self
1657 .state_snapshot
1658 .read()
1659 .map(|s| s.user_config.clone())
1660 .unwrap_or_else(|_| serde_json::json!({}));
1661
1662 rquickjs_serde::to_value(ctx, &config)
1663 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1664 }
1665
1666 pub fn reload_config(&self) {
1668 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1669 }
1670
1671 pub fn reload_themes(&self) {
1674 let _ = self
1675 .command_sender
1676 .send(PluginCommand::ReloadThemes { apply_theme: None });
1677 }
1678
1679 pub fn reload_and_apply_theme(&self, theme_name: String) {
1681 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1682 apply_theme: Some(theme_name),
1683 });
1684 }
1685
1686 pub fn register_grammar<'js>(
1689 &self,
1690 ctx: rquickjs::Ctx<'js>,
1691 language: String,
1692 grammar_path: String,
1693 extensions: Vec<String>,
1694 ) -> rquickjs::Result<bool> {
1695 {
1697 let langs = self.registered_grammar_languages.borrow();
1698 if let Some(existing_plugin) = langs.get(&language) {
1699 if existing_plugin != &self.plugin_name {
1700 let msg = format!(
1701 "Grammar for language '{}' already registered by plugin '{}'",
1702 language, existing_plugin
1703 );
1704 tracing::warn!("registerGrammar collision: {}", msg);
1705 return Err(
1706 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1707 );
1708 }
1709 }
1710 }
1711 self.registered_grammar_languages
1712 .borrow_mut()
1713 .insert(language.clone(), self.plugin_name.clone());
1714
1715 Ok(self
1716 .command_sender
1717 .send(PluginCommand::RegisterGrammar {
1718 language,
1719 grammar_path,
1720 extensions,
1721 })
1722 .is_ok())
1723 }
1724
1725 pub fn register_language_config<'js>(
1727 &self,
1728 ctx: rquickjs::Ctx<'js>,
1729 language: String,
1730 config: LanguagePackConfig,
1731 ) -> rquickjs::Result<bool> {
1732 {
1734 let langs = self.registered_language_configs.borrow();
1735 if let Some(existing_plugin) = langs.get(&language) {
1736 if existing_plugin != &self.plugin_name {
1737 let msg = format!(
1738 "Language config for '{}' already registered by plugin '{}'",
1739 language, existing_plugin
1740 );
1741 tracing::warn!("registerLanguageConfig collision: {}", msg);
1742 return Err(
1743 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1744 );
1745 }
1746 }
1747 }
1748 self.registered_language_configs
1749 .borrow_mut()
1750 .insert(language.clone(), self.plugin_name.clone());
1751
1752 Ok(self
1753 .command_sender
1754 .send(PluginCommand::RegisterLanguageConfig { language, config })
1755 .is_ok())
1756 }
1757
1758 pub fn register_lsp_server<'js>(
1760 &self,
1761 ctx: rquickjs::Ctx<'js>,
1762 language: String,
1763 config: LspServerPackConfig,
1764 ) -> rquickjs::Result<bool> {
1765 {
1767 let langs = self.registered_lsp_servers.borrow();
1768 if let Some(existing_plugin) = langs.get(&language) {
1769 if existing_plugin != &self.plugin_name {
1770 let msg = format!(
1771 "LSP server for language '{}' already registered by plugin '{}'",
1772 language, existing_plugin
1773 );
1774 tracing::warn!("registerLspServer collision: {}", msg);
1775 return Err(
1776 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1777 );
1778 }
1779 }
1780 }
1781 self.registered_lsp_servers
1782 .borrow_mut()
1783 .insert(language.clone(), self.plugin_name.clone());
1784
1785 Ok(self
1786 .command_sender
1787 .send(PluginCommand::RegisterLspServer { language, config })
1788 .is_ok())
1789 }
1790
1791 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1795 #[qjs(rename = "_reloadGrammarsStart")]
1796 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1797 let id = {
1798 let mut id_ref = self.next_request_id.borrow_mut();
1799 let id = *id_ref;
1800 *id_ref += 1;
1801 self.callback_contexts
1802 .borrow_mut()
1803 .insert(id, self.plugin_name.clone());
1804 id
1805 };
1806 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1807 callback_id: fresh_core::api::JsCallbackId::new(id),
1808 });
1809 id
1810 }
1811
1812 pub fn get_plugin_dir(&self) -> String {
1815 self.services
1816 .plugins_dir()
1817 .join("packages")
1818 .join(&self.plugin_name)
1819 .to_string_lossy()
1820 .to_string()
1821 }
1822
1823 pub fn get_config_dir(&self) -> String {
1825 self.services.config_dir().to_string_lossy().to_string()
1826 }
1827
1828 pub fn get_data_dir(&self) -> String {
1832 self.services.data_dir().to_string_lossy().to_string()
1833 }
1834
1835 pub fn get_themes_dir(&self) -> String {
1837 self.services
1838 .config_dir()
1839 .join("themes")
1840 .to_string_lossy()
1841 .to_string()
1842 }
1843
1844 pub fn apply_theme(&self, theme_name: String) -> bool {
1846 self.command_sender
1847 .send(PluginCommand::ApplyTheme { theme_name })
1848 .is_ok()
1849 }
1850
1851 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1853 let schema = self.services.get_theme_schema();
1854 rquickjs_serde::to_value(ctx, &schema)
1855 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1856 }
1857
1858 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1860 let themes = self.services.get_builtin_themes();
1861 rquickjs_serde::to_value(ctx, &themes)
1862 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1863 }
1864
1865 #[qjs(rename = "_deleteThemeSync")]
1867 pub fn delete_theme_sync(&self, name: String) -> bool {
1868 let themes_dir = self.services.config_dir().join("themes");
1870 let theme_path = themes_dir.join(format!("{}.json", name));
1871
1872 if let Ok(canonical) = theme_path.canonicalize() {
1874 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1875 if canonical.starts_with(&themes_canonical) {
1876 return std::fs::remove_file(&canonical).is_ok();
1877 }
1878 }
1879 }
1880 false
1881 }
1882
1883 pub fn delete_theme(&self, name: String) -> bool {
1885 self.delete_theme_sync(name)
1886 }
1887
1888 pub fn get_theme_data<'js>(
1890 &self,
1891 ctx: rquickjs::Ctx<'js>,
1892 name: String,
1893 ) -> rquickjs::Result<Value<'js>> {
1894 match self.services.get_theme_data(&name) {
1895 Some(data) => rquickjs_serde::to_value(ctx, &data)
1896 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1897 None => Ok(Value::new_null(ctx)),
1898 }
1899 }
1900
1901 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
1903 self.services
1904 .save_theme_file(&name, &content)
1905 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
1906 }
1907
1908 pub fn theme_file_exists(&self, name: String) -> bool {
1910 self.services.theme_file_exists(&name)
1911 }
1912
1913 pub fn file_stat<'js>(
1917 &self,
1918 ctx: rquickjs::Ctx<'js>,
1919 path: String,
1920 ) -> rquickjs::Result<Value<'js>> {
1921 let metadata = std::fs::metadata(&path).ok();
1922 let stat = metadata.map(|m| {
1923 serde_json::json!({
1924 "isFile": m.is_file(),
1925 "isDir": m.is_dir(),
1926 "size": m.len(),
1927 "readonly": m.permissions().readonly(),
1928 })
1929 });
1930 rquickjs_serde::to_value(ctx, &stat)
1931 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1932 }
1933
1934 pub fn is_process_running(&self, _process_id: u64) -> bool {
1938 false
1941 }
1942
1943 pub fn kill_process(&self, process_id: u64) -> bool {
1945 self.command_sender
1946 .send(PluginCommand::KillBackgroundProcess { process_id })
1947 .is_ok()
1948 }
1949
1950 pub fn plugin_translate<'js>(
1954 &self,
1955 _ctx: rquickjs::Ctx<'js>,
1956 plugin_name: String,
1957 key: String,
1958 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1959 ) -> String {
1960 let args_map: HashMap<String, String> = args
1961 .0
1962 .map(|obj| {
1963 let mut map = HashMap::new();
1964 for (k, v) in obj.props::<String, String>().flatten() {
1965 map.insert(k, v);
1966 }
1967 map
1968 })
1969 .unwrap_or_default();
1970
1971 self.services.translate(&plugin_name, &key, &args_map)
1972 }
1973
1974 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1981 #[qjs(rename = "_createCompositeBufferStart")]
1982 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1983 let id = {
1984 let mut id_ref = self.next_request_id.borrow_mut();
1985 let id = *id_ref;
1986 *id_ref += 1;
1987 self.callback_contexts
1989 .borrow_mut()
1990 .insert(id, self.plugin_name.clone());
1991 id
1992 };
1993
1994 if let Ok(mut owners) = self.async_resource_owners.lock() {
1996 owners.insert(id, self.plugin_name.clone());
1997 }
1998 let _ = self
1999 .command_sender
2000 .send(PluginCommand::CreateCompositeBuffer {
2001 name: opts.name,
2002 mode: opts.mode,
2003 layout: opts.layout,
2004 sources: opts.sources,
2005 hunks: opts.hunks,
2006 initial_focus_hunk: opts.initial_focus_hunk,
2007 request_id: Some(id),
2008 });
2009
2010 id
2011 }
2012
2013 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
2017 self.command_sender
2018 .send(PluginCommand::UpdateCompositeAlignment {
2019 buffer_id: BufferId(buffer_id as usize),
2020 hunks,
2021 })
2022 .is_ok()
2023 }
2024
2025 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
2027 self.command_sender
2028 .send(PluginCommand::CloseCompositeBuffer {
2029 buffer_id: BufferId(buffer_id as usize),
2030 })
2031 .is_ok()
2032 }
2033
2034 pub fn flush_layout(&self) -> bool {
2038 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
2039 }
2040
2041 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
2043 self.command_sender
2044 .send(PluginCommand::CompositeNextHunk {
2045 buffer_id: BufferId(buffer_id as usize),
2046 })
2047 .is_ok()
2048 }
2049
2050 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
2052 self.command_sender
2053 .send(PluginCommand::CompositePrevHunk {
2054 buffer_id: BufferId(buffer_id as usize),
2055 })
2056 .is_ok()
2057 }
2058
2059 #[plugin_api(
2063 async_promise,
2064 js_name = "getHighlights",
2065 ts_return = "TsHighlightSpan[]"
2066 )]
2067 #[qjs(rename = "_getHighlightsStart")]
2068 pub fn get_highlights_start<'js>(
2069 &self,
2070 _ctx: rquickjs::Ctx<'js>,
2071 buffer_id: u32,
2072 start: u32,
2073 end: u32,
2074 ) -> rquickjs::Result<u64> {
2075 let id = {
2076 let mut id_ref = self.next_request_id.borrow_mut();
2077 let id = *id_ref;
2078 *id_ref += 1;
2079 self.callback_contexts
2081 .borrow_mut()
2082 .insert(id, self.plugin_name.clone());
2083 id
2084 };
2085
2086 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2087 buffer_id: BufferId(buffer_id as usize),
2088 range: (start as usize)..(end as usize),
2089 request_id: id,
2090 });
2091
2092 Ok(id)
2093 }
2094
2095 pub fn add_overlay<'js>(
2117 &self,
2118 _ctx: rquickjs::Ctx<'js>,
2119 buffer_id: u32,
2120 namespace: String,
2121 start: u32,
2122 end: u32,
2123 options: rquickjs::Object<'js>,
2124 ) -> rquickjs::Result<bool> {
2125 use fresh_core::api::OverlayColorSpec;
2126
2127 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2129 if let Ok(theme_key) = obj.get::<_, String>(key) {
2131 if !theme_key.is_empty() {
2132 return Some(OverlayColorSpec::ThemeKey(theme_key));
2133 }
2134 }
2135 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2137 if arr.len() >= 3 {
2138 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2139 }
2140 }
2141 None
2142 }
2143
2144 let fg = parse_color_spec("fg", &options);
2145 let bg = parse_color_spec("bg", &options);
2146 let underline: bool = options.get("underline").unwrap_or(false);
2147 let bold: bool = options.get("bold").unwrap_or(false);
2148 let italic: bool = options.get("italic").unwrap_or(false);
2149 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2150 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2151 let url: Option<String> = options.get("url").ok();
2152
2153 let options = OverlayOptions {
2154 fg,
2155 bg,
2156 underline,
2157 bold,
2158 italic,
2159 strikethrough,
2160 extend_to_line_end,
2161 url,
2162 };
2163
2164 self.plugin_tracked_state
2166 .borrow_mut()
2167 .entry(self.plugin_name.clone())
2168 .or_default()
2169 .overlay_namespaces
2170 .push((BufferId(buffer_id as usize), namespace.clone()));
2171
2172 let _ = self.command_sender.send(PluginCommand::AddOverlay {
2173 buffer_id: BufferId(buffer_id as usize),
2174 namespace: Some(OverlayNamespace::from_string(namespace)),
2175 range: (start as usize)..(end as usize),
2176 options,
2177 });
2178
2179 Ok(true)
2180 }
2181
2182 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2184 self.command_sender
2185 .send(PluginCommand::ClearNamespace {
2186 buffer_id: BufferId(buffer_id as usize),
2187 namespace: OverlayNamespace::from_string(namespace),
2188 })
2189 .is_ok()
2190 }
2191
2192 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2194 self.command_sender
2195 .send(PluginCommand::ClearAllOverlays {
2196 buffer_id: BufferId(buffer_id as usize),
2197 })
2198 .is_ok()
2199 }
2200
2201 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2203 self.command_sender
2204 .send(PluginCommand::ClearOverlaysInRange {
2205 buffer_id: BufferId(buffer_id as usize),
2206 start: start as usize,
2207 end: end as usize,
2208 })
2209 .is_ok()
2210 }
2211
2212 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2214 use fresh_core::overlay::OverlayHandle;
2215 self.command_sender
2216 .send(PluginCommand::RemoveOverlay {
2217 buffer_id: BufferId(buffer_id as usize),
2218 handle: OverlayHandle(handle),
2219 })
2220 .is_ok()
2221 }
2222
2223 pub fn add_conceal(
2227 &self,
2228 buffer_id: u32,
2229 namespace: String,
2230 start: u32,
2231 end: u32,
2232 replacement: Option<String>,
2233 ) -> bool {
2234 self.plugin_tracked_state
2236 .borrow_mut()
2237 .entry(self.plugin_name.clone())
2238 .or_default()
2239 .overlay_namespaces
2240 .push((BufferId(buffer_id as usize), namespace.clone()));
2241
2242 self.command_sender
2243 .send(PluginCommand::AddConceal {
2244 buffer_id: BufferId(buffer_id as usize),
2245 namespace: OverlayNamespace::from_string(namespace),
2246 start: start as usize,
2247 end: end as usize,
2248 replacement,
2249 })
2250 .is_ok()
2251 }
2252
2253 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2255 self.command_sender
2256 .send(PluginCommand::ClearConcealNamespace {
2257 buffer_id: BufferId(buffer_id as usize),
2258 namespace: OverlayNamespace::from_string(namespace),
2259 })
2260 .is_ok()
2261 }
2262
2263 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2265 self.command_sender
2266 .send(PluginCommand::ClearConcealsInRange {
2267 buffer_id: BufferId(buffer_id as usize),
2268 start: start as usize,
2269 end: end as usize,
2270 })
2271 .is_ok()
2272 }
2273
2274 pub fn add_fold(
2281 &self,
2282 buffer_id: u32,
2283 start: u32,
2284 end: u32,
2285 placeholder: rquickjs::function::Opt<String>,
2286 ) -> bool {
2287 self.command_sender
2288 .send(PluginCommand::AddFold {
2289 buffer_id: BufferId(buffer_id as usize),
2290 start: start as usize,
2291 end: end as usize,
2292 placeholder: placeholder.0,
2293 })
2294 .is_ok()
2295 }
2296
2297 pub fn clear_folds(&self, buffer_id: u32) -> bool {
2299 self.command_sender
2300 .send(PluginCommand::ClearFolds {
2301 buffer_id: BufferId(buffer_id as usize),
2302 })
2303 .is_ok()
2304 }
2305
2306 pub fn add_soft_break(
2310 &self,
2311 buffer_id: u32,
2312 namespace: String,
2313 position: u32,
2314 indent: u32,
2315 ) -> bool {
2316 self.plugin_tracked_state
2318 .borrow_mut()
2319 .entry(self.plugin_name.clone())
2320 .or_default()
2321 .overlay_namespaces
2322 .push((BufferId(buffer_id as usize), namespace.clone()));
2323
2324 self.command_sender
2325 .send(PluginCommand::AddSoftBreak {
2326 buffer_id: BufferId(buffer_id as usize),
2327 namespace: OverlayNamespace::from_string(namespace),
2328 position: position as usize,
2329 indent: indent as u16,
2330 })
2331 .is_ok()
2332 }
2333
2334 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2336 self.command_sender
2337 .send(PluginCommand::ClearSoftBreakNamespace {
2338 buffer_id: BufferId(buffer_id as usize),
2339 namespace: OverlayNamespace::from_string(namespace),
2340 })
2341 .is_ok()
2342 }
2343
2344 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2346 self.command_sender
2347 .send(PluginCommand::ClearSoftBreaksInRange {
2348 buffer_id: BufferId(buffer_id as usize),
2349 start: start as usize,
2350 end: end as usize,
2351 })
2352 .is_ok()
2353 }
2354
2355 #[allow(clippy::too_many_arguments)]
2365 pub fn submit_view_transform<'js>(
2366 &self,
2367 _ctx: rquickjs::Ctx<'js>,
2368 buffer_id: u32,
2369 split_id: Option<u32>,
2370 start: u32,
2371 end: u32,
2372 tokens: Vec<rquickjs::Object<'js>>,
2373 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2374 ) -> rquickjs::Result<bool> {
2375 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2376
2377 let tokens: Vec<ViewTokenWire> = tokens
2378 .into_iter()
2379 .enumerate()
2380 .map(|(idx, obj)| {
2381 parse_view_token(&obj, idx)
2383 })
2384 .collect::<rquickjs::Result<Vec<_>>>()?;
2385
2386 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2388 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2389 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2390 Some(LayoutHints {
2391 compose_width,
2392 column_guides,
2393 })
2394 } else {
2395 None
2396 };
2397
2398 let payload = ViewTransformPayload {
2399 range: (start as usize)..(end as usize),
2400 tokens,
2401 layout_hints: parsed_layout_hints,
2402 };
2403
2404 Ok(self
2405 .command_sender
2406 .send(PluginCommand::SubmitViewTransform {
2407 buffer_id: BufferId(buffer_id as usize),
2408 split_id: split_id.map(|id| SplitId(id as usize)),
2409 payload,
2410 })
2411 .is_ok())
2412 }
2413
2414 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2416 self.command_sender
2417 .send(PluginCommand::ClearViewTransform {
2418 buffer_id: BufferId(buffer_id as usize),
2419 split_id: split_id.map(|id| SplitId(id as usize)),
2420 })
2421 .is_ok()
2422 }
2423
2424 pub fn set_layout_hints<'js>(
2427 &self,
2428 buffer_id: u32,
2429 split_id: Option<u32>,
2430 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2431 ) -> rquickjs::Result<bool> {
2432 use fresh_core::api::LayoutHints;
2433
2434 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2435 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2436 let parsed_hints = LayoutHints {
2437 compose_width,
2438 column_guides,
2439 };
2440
2441 Ok(self
2442 .command_sender
2443 .send(PluginCommand::SetLayoutHints {
2444 buffer_id: BufferId(buffer_id as usize),
2445 split_id: split_id.map(|id| SplitId(id as usize)),
2446 range: 0..0,
2447 hints: parsed_hints,
2448 })
2449 .is_ok())
2450 }
2451
2452 pub fn set_file_explorer_decorations<'js>(
2456 &self,
2457 _ctx: rquickjs::Ctx<'js>,
2458 namespace: String,
2459 decorations: Vec<rquickjs::Object<'js>>,
2460 ) -> rquickjs::Result<bool> {
2461 use fresh_core::file_explorer::FileExplorerDecoration;
2462
2463 let decorations: Vec<FileExplorerDecoration> = decorations
2464 .into_iter()
2465 .map(|obj| {
2466 let path: String = obj.get("path")?;
2467 let symbol: String = obj.get("symbol")?;
2468 let priority: i32 = obj.get("priority").unwrap_or(0);
2469
2470 let color_val: rquickjs::Value = obj.get("color")?;
2472 let color = if color_val.is_string() {
2473 let key: String = color_val.get()?;
2474 fresh_core::api::OverlayColorSpec::ThemeKey(key)
2475 } else if color_val.is_array() {
2476 let arr: Vec<u8> = color_val.get()?;
2477 if arr.len() < 3 {
2478 return Err(rquickjs::Error::FromJs {
2479 from: "array",
2480 to: "color",
2481 message: Some(format!(
2482 "color array must have at least 3 elements, got {}",
2483 arr.len()
2484 )),
2485 });
2486 }
2487 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
2488 } else {
2489 return Err(rquickjs::Error::FromJs {
2490 from: "value",
2491 to: "color",
2492 message: Some("color must be an RGB array or theme key string".to_string()),
2493 });
2494 };
2495
2496 Ok(FileExplorerDecoration {
2497 path: std::path::PathBuf::from(path),
2498 symbol,
2499 color,
2500 priority,
2501 })
2502 })
2503 .collect::<rquickjs::Result<Vec<_>>>()?;
2504
2505 self.plugin_tracked_state
2507 .borrow_mut()
2508 .entry(self.plugin_name.clone())
2509 .or_default()
2510 .file_explorer_namespaces
2511 .push(namespace.clone());
2512
2513 Ok(self
2514 .command_sender
2515 .send(PluginCommand::SetFileExplorerDecorations {
2516 namespace,
2517 decorations,
2518 })
2519 .is_ok())
2520 }
2521
2522 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2524 self.command_sender
2525 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2526 .is_ok()
2527 }
2528
2529 #[allow(clippy::too_many_arguments)]
2533 pub fn add_virtual_text(
2534 &self,
2535 buffer_id: u32,
2536 virtual_text_id: String,
2537 position: u32,
2538 text: String,
2539 r: u8,
2540 g: u8,
2541 b: u8,
2542 before: bool,
2543 use_bg: bool,
2544 ) -> bool {
2545 self.plugin_tracked_state
2547 .borrow_mut()
2548 .entry(self.plugin_name.clone())
2549 .or_default()
2550 .virtual_text_ids
2551 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2552
2553 self.command_sender
2554 .send(PluginCommand::AddVirtualText {
2555 buffer_id: BufferId(buffer_id as usize),
2556 virtual_text_id,
2557 position: position as usize,
2558 text,
2559 color: (r, g, b),
2560 use_bg,
2561 before,
2562 })
2563 .is_ok()
2564 }
2565
2566 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2568 self.command_sender
2569 .send(PluginCommand::RemoveVirtualText {
2570 buffer_id: BufferId(buffer_id as usize),
2571 virtual_text_id,
2572 })
2573 .is_ok()
2574 }
2575
2576 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2578 self.command_sender
2579 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2580 buffer_id: BufferId(buffer_id as usize),
2581 prefix,
2582 })
2583 .is_ok()
2584 }
2585
2586 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2588 self.command_sender
2589 .send(PluginCommand::ClearVirtualTexts {
2590 buffer_id: BufferId(buffer_id as usize),
2591 })
2592 .is_ok()
2593 }
2594
2595 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2597 self.command_sender
2598 .send(PluginCommand::ClearVirtualTextNamespace {
2599 buffer_id: BufferId(buffer_id as usize),
2600 namespace,
2601 })
2602 .is_ok()
2603 }
2604
2605 #[allow(clippy::too_many_arguments)]
2613 pub fn add_virtual_line<'js>(
2614 &self,
2615 _ctx: rquickjs::Ctx<'js>,
2616 buffer_id: u32,
2617 position: u32,
2618 text: String,
2619 options: rquickjs::Object<'js>,
2620 above: bool,
2621 namespace: String,
2622 priority: i32,
2623 ) -> rquickjs::Result<bool> {
2624 use fresh_core::api::OverlayColorSpec;
2625
2626 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2629 if let Ok(theme_key) = obj.get::<_, String>(key) {
2630 if !theme_key.is_empty() {
2631 return Some(OverlayColorSpec::ThemeKey(theme_key));
2632 }
2633 }
2634 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2635 if arr.len() >= 3 {
2636 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2637 }
2638 }
2639 None
2640 }
2641
2642 let fg_color = parse_color_spec("fg", &options);
2643 let bg_color = parse_color_spec("bg", &options);
2644
2645 self.plugin_tracked_state
2647 .borrow_mut()
2648 .entry(self.plugin_name.clone())
2649 .or_default()
2650 .virtual_line_namespaces
2651 .push((BufferId(buffer_id as usize), namespace.clone()));
2652
2653 Ok(self
2654 .command_sender
2655 .send(PluginCommand::AddVirtualLine {
2656 buffer_id: BufferId(buffer_id as usize),
2657 position: position as usize,
2658 text,
2659 fg_color,
2660 bg_color,
2661 above,
2662 namespace,
2663 priority,
2664 })
2665 .is_ok())
2666 }
2667
2668 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2673 #[qjs(rename = "_promptStart")]
2674 pub fn prompt_start(
2675 &self,
2676 _ctx: rquickjs::Ctx<'_>,
2677 label: String,
2678 initial_value: String,
2679 ) -> u64 {
2680 let id = {
2681 let mut id_ref = self.next_request_id.borrow_mut();
2682 let id = *id_ref;
2683 *id_ref += 1;
2684 self.callback_contexts
2686 .borrow_mut()
2687 .insert(id, self.plugin_name.clone());
2688 id
2689 };
2690
2691 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2692 label,
2693 initial_value,
2694 callback_id: JsCallbackId::new(id),
2695 });
2696
2697 id
2698 }
2699
2700 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2702 self.command_sender
2703 .send(PluginCommand::StartPrompt { label, prompt_type })
2704 .is_ok()
2705 }
2706
2707 pub fn start_prompt_with_initial(
2709 &self,
2710 label: String,
2711 prompt_type: String,
2712 initial_value: String,
2713 ) -> bool {
2714 self.command_sender
2715 .send(PluginCommand::StartPromptWithInitial {
2716 label,
2717 prompt_type,
2718 initial_value,
2719 })
2720 .is_ok()
2721 }
2722
2723 pub fn set_prompt_suggestions(
2727 &self,
2728 suggestions: Vec<fresh_core::command::Suggestion>,
2729 ) -> bool {
2730 self.command_sender
2731 .send(PluginCommand::SetPromptSuggestions { suggestions })
2732 .is_ok()
2733 }
2734
2735 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2736 self.command_sender
2737 .send(PluginCommand::SetPromptInputSync { sync })
2738 .is_ok()
2739 }
2740
2741 pub fn define_mode(
2745 &self,
2746 name: String,
2747 bindings_arr: Vec<Vec<String>>,
2748 read_only: rquickjs::function::Opt<bool>,
2749 allow_text_input: rquickjs::function::Opt<bool>,
2750 inherit_normal_bindings: rquickjs::function::Opt<bool>,
2751 ) -> bool {
2752 let bindings: Vec<(String, String)> = bindings_arr
2753 .into_iter()
2754 .filter_map(|arr| {
2755 if arr.len() >= 2 {
2756 Some((arr[0].clone(), arr[1].clone()))
2757 } else {
2758 None
2759 }
2760 })
2761 .collect();
2762
2763 {
2766 let mut registered = self.registered_actions.borrow_mut();
2767 for (_, cmd_name) in &bindings {
2768 registered.insert(
2769 cmd_name.clone(),
2770 PluginHandler {
2771 plugin_name: self.plugin_name.clone(),
2772 handler_name: cmd_name.clone(),
2773 },
2774 );
2775 }
2776 }
2777
2778 let allow_text = allow_text_input.0.unwrap_or(false);
2781 if allow_text {
2782 let mut registered = self.registered_actions.borrow_mut();
2783 registered.insert(
2784 "mode_text_input".to_string(),
2785 PluginHandler {
2786 plugin_name: self.plugin_name.clone(),
2787 handler_name: "mode_text_input".to_string(),
2788 },
2789 );
2790 }
2791
2792 self.command_sender
2793 .send(PluginCommand::DefineMode {
2794 name,
2795 bindings,
2796 read_only: read_only.0.unwrap_or(false),
2797 allow_text_input: allow_text,
2798 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
2799 plugin_name: Some(self.plugin_name.clone()),
2800 })
2801 .is_ok()
2802 }
2803
2804 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2806 self.command_sender
2807 .send(PluginCommand::SetEditorMode { mode })
2808 .is_ok()
2809 }
2810
2811 pub fn get_editor_mode(&self) -> Option<String> {
2813 self.state_snapshot
2814 .read()
2815 .ok()
2816 .and_then(|s| s.editor_mode.clone())
2817 }
2818
2819 pub fn close_split(&self, split_id: u32) -> bool {
2823 self.command_sender
2824 .send(PluginCommand::CloseSplit {
2825 split_id: SplitId(split_id as usize),
2826 })
2827 .is_ok()
2828 }
2829
2830 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2832 self.command_sender
2833 .send(PluginCommand::SetSplitBuffer {
2834 split_id: SplitId(split_id as usize),
2835 buffer_id: BufferId(buffer_id as usize),
2836 })
2837 .is_ok()
2838 }
2839
2840 pub fn focus_split(&self, split_id: u32) -> bool {
2842 self.command_sender
2843 .send(PluginCommand::FocusSplit {
2844 split_id: SplitId(split_id as usize),
2845 })
2846 .is_ok()
2847 }
2848
2849 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2851 self.command_sender
2852 .send(PluginCommand::SetSplitScroll {
2853 split_id: SplitId(split_id as usize),
2854 top_byte: top_byte as usize,
2855 })
2856 .is_ok()
2857 }
2858
2859 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2861 self.command_sender
2862 .send(PluginCommand::SetSplitRatio {
2863 split_id: SplitId(split_id as usize),
2864 ratio,
2865 })
2866 .is_ok()
2867 }
2868
2869 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2871 self.command_sender
2872 .send(PluginCommand::SetSplitLabel {
2873 split_id: SplitId(split_id as usize),
2874 label,
2875 })
2876 .is_ok()
2877 }
2878
2879 pub fn clear_split_label(&self, split_id: u32) -> bool {
2881 self.command_sender
2882 .send(PluginCommand::ClearSplitLabel {
2883 split_id: SplitId(split_id as usize),
2884 })
2885 .is_ok()
2886 }
2887
2888 #[plugin_api(
2890 async_promise,
2891 js_name = "getSplitByLabel",
2892 ts_return = "number | null"
2893 )]
2894 #[qjs(rename = "_getSplitByLabelStart")]
2895 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2896 let id = {
2897 let mut id_ref = self.next_request_id.borrow_mut();
2898 let id = *id_ref;
2899 *id_ref += 1;
2900 self.callback_contexts
2901 .borrow_mut()
2902 .insert(id, self.plugin_name.clone());
2903 id
2904 };
2905 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2906 label,
2907 request_id: id,
2908 });
2909 id
2910 }
2911
2912 pub fn distribute_splits_evenly(&self) -> bool {
2914 self.command_sender
2916 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2917 .is_ok()
2918 }
2919
2920 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2922 self.command_sender
2923 .send(PluginCommand::SetBufferCursor {
2924 buffer_id: BufferId(buffer_id as usize),
2925 position: position as usize,
2926 })
2927 .is_ok()
2928 }
2929
2930 #[qjs(rename = "setBufferShowCursors")]
2937 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
2938 self.command_sender
2939 .send(PluginCommand::SetBufferShowCursors {
2940 buffer_id: BufferId(buffer_id as usize),
2941 show,
2942 })
2943 .is_ok()
2944 }
2945
2946 #[allow(clippy::too_many_arguments)]
2950 pub fn set_line_indicator(
2951 &self,
2952 buffer_id: u32,
2953 line: u32,
2954 namespace: String,
2955 symbol: String,
2956 r: u8,
2957 g: u8,
2958 b: u8,
2959 priority: i32,
2960 ) -> bool {
2961 self.plugin_tracked_state
2963 .borrow_mut()
2964 .entry(self.plugin_name.clone())
2965 .or_default()
2966 .line_indicator_namespaces
2967 .push((BufferId(buffer_id as usize), namespace.clone()));
2968
2969 self.command_sender
2970 .send(PluginCommand::SetLineIndicator {
2971 buffer_id: BufferId(buffer_id as usize),
2972 line: line as usize,
2973 namespace,
2974 symbol,
2975 color: (r, g, b),
2976 priority,
2977 })
2978 .is_ok()
2979 }
2980
2981 #[allow(clippy::too_many_arguments)]
2983 pub fn set_line_indicators(
2984 &self,
2985 buffer_id: u32,
2986 lines: Vec<u32>,
2987 namespace: String,
2988 symbol: String,
2989 r: u8,
2990 g: u8,
2991 b: u8,
2992 priority: i32,
2993 ) -> bool {
2994 self.plugin_tracked_state
2996 .borrow_mut()
2997 .entry(self.plugin_name.clone())
2998 .or_default()
2999 .line_indicator_namespaces
3000 .push((BufferId(buffer_id as usize), namespace.clone()));
3001
3002 self.command_sender
3003 .send(PluginCommand::SetLineIndicators {
3004 buffer_id: BufferId(buffer_id as usize),
3005 lines: lines.into_iter().map(|l| l as usize).collect(),
3006 namespace,
3007 symbol,
3008 color: (r, g, b),
3009 priority,
3010 })
3011 .is_ok()
3012 }
3013
3014 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
3016 self.command_sender
3017 .send(PluginCommand::ClearLineIndicators {
3018 buffer_id: BufferId(buffer_id as usize),
3019 namespace,
3020 })
3021 .is_ok()
3022 }
3023
3024 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
3026 self.command_sender
3027 .send(PluginCommand::SetLineNumbers {
3028 buffer_id: BufferId(buffer_id as usize),
3029 enabled,
3030 })
3031 .is_ok()
3032 }
3033
3034 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
3036 self.command_sender
3037 .send(PluginCommand::SetViewMode {
3038 buffer_id: BufferId(buffer_id as usize),
3039 mode,
3040 })
3041 .is_ok()
3042 }
3043
3044 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
3046 self.command_sender
3047 .send(PluginCommand::SetLineWrap {
3048 buffer_id: BufferId(buffer_id as usize),
3049 split_id: split_id.map(|s| SplitId(s as usize)),
3050 enabled,
3051 })
3052 .is_ok()
3053 }
3054
3055 pub fn set_view_state<'js>(
3059 &self,
3060 ctx: rquickjs::Ctx<'js>,
3061 buffer_id: u32,
3062 key: String,
3063 value: Value<'js>,
3064 ) -> bool {
3065 let bid = BufferId(buffer_id as usize);
3066
3067 let json_value = if value.is_undefined() || value.is_null() {
3069 None
3070 } else {
3071 Some(js_to_json(&ctx, value))
3072 };
3073
3074 if let Ok(mut snapshot) = self.state_snapshot.write() {
3076 if let Some(ref json_val) = json_value {
3077 snapshot
3078 .plugin_view_states
3079 .entry(bid)
3080 .or_default()
3081 .insert(key.clone(), json_val.clone());
3082 } else {
3083 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
3085 map.remove(&key);
3086 if map.is_empty() {
3087 snapshot.plugin_view_states.remove(&bid);
3088 }
3089 }
3090 }
3091 }
3092
3093 self.command_sender
3095 .send(PluginCommand::SetViewState {
3096 buffer_id: bid,
3097 key,
3098 value: json_value,
3099 })
3100 .is_ok()
3101 }
3102
3103 pub fn get_view_state<'js>(
3105 &self,
3106 ctx: rquickjs::Ctx<'js>,
3107 buffer_id: u32,
3108 key: String,
3109 ) -> rquickjs::Result<Value<'js>> {
3110 let bid = BufferId(buffer_id as usize);
3111 if let Ok(snapshot) = self.state_snapshot.read() {
3112 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
3113 if let Some(json_val) = map.get(&key) {
3114 return json_to_js_value(&ctx, json_val);
3115 }
3116 }
3117 }
3118 Ok(Value::new_undefined(ctx.clone()))
3119 }
3120
3121 pub fn set_global_state<'js>(
3127 &self,
3128 ctx: rquickjs::Ctx<'js>,
3129 key: String,
3130 value: Value<'js>,
3131 ) -> bool {
3132 let json_value = if value.is_undefined() || value.is_null() {
3134 None
3135 } else {
3136 Some(js_to_json(&ctx, value))
3137 };
3138
3139 if let Ok(mut snapshot) = self.state_snapshot.write() {
3141 if let Some(ref json_val) = json_value {
3142 snapshot
3143 .plugin_global_states
3144 .entry(self.plugin_name.clone())
3145 .or_default()
3146 .insert(key.clone(), json_val.clone());
3147 } else {
3148 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3150 map.remove(&key);
3151 if map.is_empty() {
3152 snapshot.plugin_global_states.remove(&self.plugin_name);
3153 }
3154 }
3155 }
3156 }
3157
3158 self.command_sender
3160 .send(PluginCommand::SetGlobalState {
3161 plugin_name: self.plugin_name.clone(),
3162 key,
3163 value: json_value,
3164 })
3165 .is_ok()
3166 }
3167
3168 pub fn get_global_state<'js>(
3172 &self,
3173 ctx: rquickjs::Ctx<'js>,
3174 key: String,
3175 ) -> rquickjs::Result<Value<'js>> {
3176 if let Ok(snapshot) = self.state_snapshot.read() {
3177 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3178 if let Some(json_val) = map.get(&key) {
3179 return json_to_js_value(&ctx, json_val);
3180 }
3181 }
3182 }
3183 Ok(Value::new_undefined(ctx.clone()))
3184 }
3185
3186 pub fn create_scroll_sync_group(
3190 &self,
3191 group_id: u32,
3192 left_split: u32,
3193 right_split: u32,
3194 ) -> bool {
3195 self.plugin_tracked_state
3197 .borrow_mut()
3198 .entry(self.plugin_name.clone())
3199 .or_default()
3200 .scroll_sync_group_ids
3201 .push(group_id);
3202 self.command_sender
3203 .send(PluginCommand::CreateScrollSyncGroup {
3204 group_id,
3205 left_split: SplitId(left_split as usize),
3206 right_split: SplitId(right_split as usize),
3207 })
3208 .is_ok()
3209 }
3210
3211 pub fn set_scroll_sync_anchors<'js>(
3213 &self,
3214 _ctx: rquickjs::Ctx<'js>,
3215 group_id: u32,
3216 anchors: Vec<Vec<u32>>,
3217 ) -> bool {
3218 let anchors: Vec<(usize, usize)> = anchors
3219 .into_iter()
3220 .filter_map(|pair| {
3221 if pair.len() >= 2 {
3222 Some((pair[0] as usize, pair[1] as usize))
3223 } else {
3224 None
3225 }
3226 })
3227 .collect();
3228 self.command_sender
3229 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3230 .is_ok()
3231 }
3232
3233 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3235 self.command_sender
3236 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3237 .is_ok()
3238 }
3239
3240 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3246 self.command_sender
3247 .send(PluginCommand::ExecuteActions { actions })
3248 .is_ok()
3249 }
3250
3251 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3255 self.command_sender
3256 .send(PluginCommand::ShowActionPopup {
3257 popup_id: opts.id,
3258 title: opts.title,
3259 message: opts.message,
3260 actions: opts.actions,
3261 })
3262 .is_ok()
3263 }
3264
3265 pub fn disable_lsp_for_language(&self, language: String) -> bool {
3267 self.command_sender
3268 .send(PluginCommand::DisableLspForLanguage { language })
3269 .is_ok()
3270 }
3271
3272 pub fn restart_lsp_for_language(&self, language: String) -> bool {
3274 self.command_sender
3275 .send(PluginCommand::RestartLspForLanguage { language })
3276 .is_ok()
3277 }
3278
3279 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3282 self.command_sender
3283 .send(PluginCommand::SetLspRootUri { language, uri })
3284 .is_ok()
3285 }
3286
3287 #[plugin_api(ts_return = "JsDiagnostic[]")]
3289 pub fn get_all_diagnostics<'js>(
3290 &self,
3291 ctx: rquickjs::Ctx<'js>,
3292 ) -> rquickjs::Result<Value<'js>> {
3293 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3294
3295 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3296 let mut result: Vec<JsDiagnostic> = Vec::new();
3298 for (uri, diags) in &s.diagnostics {
3299 for diag in diags {
3300 result.push(JsDiagnostic {
3301 uri: uri.clone(),
3302 message: diag.message.clone(),
3303 severity: diag.severity.map(|s| match s {
3304 lsp_types::DiagnosticSeverity::ERROR => 1,
3305 lsp_types::DiagnosticSeverity::WARNING => 2,
3306 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3307 lsp_types::DiagnosticSeverity::HINT => 4,
3308 _ => 0,
3309 }),
3310 range: JsRange {
3311 start: JsPosition {
3312 line: diag.range.start.line,
3313 character: diag.range.start.character,
3314 },
3315 end: JsPosition {
3316 line: diag.range.end.line,
3317 character: diag.range.end.character,
3318 },
3319 },
3320 source: diag.source.clone(),
3321 });
3322 }
3323 }
3324 result
3325 } else {
3326 Vec::new()
3327 };
3328 rquickjs_serde::to_value(ctx, &diagnostics)
3329 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3330 }
3331
3332 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3334 self.event_handlers
3335 .borrow()
3336 .get(&event_name)
3337 .cloned()
3338 .unwrap_or_default()
3339 .into_iter()
3340 .map(|h| h.handler_name)
3341 .collect()
3342 }
3343
3344 #[plugin_api(
3348 async_promise,
3349 js_name = "createVirtualBuffer",
3350 ts_return = "VirtualBufferResult"
3351 )]
3352 #[qjs(rename = "_createVirtualBufferStart")]
3353 pub fn create_virtual_buffer_start(
3354 &self,
3355 _ctx: rquickjs::Ctx<'_>,
3356 opts: fresh_core::api::CreateVirtualBufferOptions,
3357 ) -> rquickjs::Result<u64> {
3358 let id = {
3359 let mut id_ref = self.next_request_id.borrow_mut();
3360 let id = *id_ref;
3361 *id_ref += 1;
3362 self.callback_contexts
3364 .borrow_mut()
3365 .insert(id, self.plugin_name.clone());
3366 id
3367 };
3368
3369 let entries: Vec<TextPropertyEntry> = opts
3371 .entries
3372 .unwrap_or_default()
3373 .into_iter()
3374 .map(|e| TextPropertyEntry {
3375 text: e.text,
3376 properties: e.properties.unwrap_or_default(),
3377 style: e.style,
3378 inline_overlays: e.inline_overlays.unwrap_or_default(),
3379 })
3380 .collect();
3381
3382 tracing::debug!(
3383 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3384 id
3385 );
3386 if let Ok(mut owners) = self.async_resource_owners.lock() {
3388 owners.insert(id, self.plugin_name.clone());
3389 }
3390 let _ = self
3391 .command_sender
3392 .send(PluginCommand::CreateVirtualBufferWithContent {
3393 name: opts.name,
3394 mode: opts.mode.unwrap_or_default(),
3395 read_only: opts.read_only.unwrap_or(false),
3396 entries,
3397 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3398 show_cursors: opts.show_cursors.unwrap_or(true),
3399 editing_disabled: opts.editing_disabled.unwrap_or(false),
3400 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3401 request_id: Some(id),
3402 });
3403 Ok(id)
3404 }
3405
3406 #[plugin_api(
3408 async_promise,
3409 js_name = "createVirtualBufferInSplit",
3410 ts_return = "VirtualBufferResult"
3411 )]
3412 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3413 pub fn create_virtual_buffer_in_split_start(
3414 &self,
3415 _ctx: rquickjs::Ctx<'_>,
3416 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3417 ) -> rquickjs::Result<u64> {
3418 let id = {
3419 let mut id_ref = self.next_request_id.borrow_mut();
3420 let id = *id_ref;
3421 *id_ref += 1;
3422 self.callback_contexts
3424 .borrow_mut()
3425 .insert(id, self.plugin_name.clone());
3426 id
3427 };
3428
3429 let entries: Vec<TextPropertyEntry> = opts
3431 .entries
3432 .unwrap_or_default()
3433 .into_iter()
3434 .map(|e| TextPropertyEntry {
3435 text: e.text,
3436 properties: e.properties.unwrap_or_default(),
3437 style: e.style,
3438 inline_overlays: e.inline_overlays.unwrap_or_default(),
3439 })
3440 .collect();
3441
3442 if let Ok(mut owners) = self.async_resource_owners.lock() {
3444 owners.insert(id, self.plugin_name.clone());
3445 }
3446 let _ = self
3447 .command_sender
3448 .send(PluginCommand::CreateVirtualBufferInSplit {
3449 name: opts.name,
3450 mode: opts.mode.unwrap_or_default(),
3451 read_only: opts.read_only.unwrap_or(false),
3452 entries,
3453 ratio: opts.ratio.unwrap_or(0.5),
3454 direction: opts.direction,
3455 panel_id: opts.panel_id,
3456 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3457 show_cursors: opts.show_cursors.unwrap_or(true),
3458 editing_disabled: opts.editing_disabled.unwrap_or(false),
3459 line_wrap: opts.line_wrap,
3460 before: opts.before.unwrap_or(false),
3461 request_id: Some(id),
3462 });
3463 Ok(id)
3464 }
3465
3466 #[plugin_api(
3468 async_promise,
3469 js_name = "createVirtualBufferInExistingSplit",
3470 ts_return = "VirtualBufferResult"
3471 )]
3472 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3473 pub fn create_virtual_buffer_in_existing_split_start(
3474 &self,
3475 _ctx: rquickjs::Ctx<'_>,
3476 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3477 ) -> rquickjs::Result<u64> {
3478 let id = {
3479 let mut id_ref = self.next_request_id.borrow_mut();
3480 let id = *id_ref;
3481 *id_ref += 1;
3482 self.callback_contexts
3484 .borrow_mut()
3485 .insert(id, self.plugin_name.clone());
3486 id
3487 };
3488
3489 let entries: Vec<TextPropertyEntry> = opts
3491 .entries
3492 .unwrap_or_default()
3493 .into_iter()
3494 .map(|e| TextPropertyEntry {
3495 text: e.text,
3496 properties: e.properties.unwrap_or_default(),
3497 style: e.style,
3498 inline_overlays: e.inline_overlays.unwrap_or_default(),
3499 })
3500 .collect();
3501
3502 if let Ok(mut owners) = self.async_resource_owners.lock() {
3504 owners.insert(id, self.plugin_name.clone());
3505 }
3506 let _ = self
3507 .command_sender
3508 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3509 name: opts.name,
3510 mode: opts.mode.unwrap_or_default(),
3511 read_only: opts.read_only.unwrap_or(false),
3512 entries,
3513 split_id: SplitId(opts.split_id),
3514 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3515 show_cursors: opts.show_cursors.unwrap_or(true),
3516 editing_disabled: opts.editing_disabled.unwrap_or(false),
3517 line_wrap: opts.line_wrap,
3518 request_id: Some(id),
3519 });
3520 Ok(id)
3521 }
3522
3523 #[qjs(rename = "_createBufferGroupStart")]
3525 pub fn create_buffer_group_start(
3526 &self,
3527 _ctx: rquickjs::Ctx<'_>,
3528 name: String,
3529 mode: String,
3530 layout_json: String,
3531 ) -> rquickjs::Result<u64> {
3532 let id = {
3533 let mut id_ref = self.next_request_id.borrow_mut();
3534 let id = *id_ref;
3535 *id_ref += 1;
3536 self.callback_contexts
3537 .borrow_mut()
3538 .insert(id, self.plugin_name.clone());
3539 id
3540 };
3541 if let Ok(mut owners) = self.async_resource_owners.lock() {
3542 owners.insert(id, self.plugin_name.clone());
3543 }
3544 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
3545 name,
3546 mode,
3547 layout_json,
3548 request_id: Some(id),
3549 });
3550 Ok(id)
3551 }
3552
3553 #[qjs(rename = "setPanelContent")]
3555 pub fn set_panel_content<'js>(
3556 &self,
3557 ctx: rquickjs::Ctx<'js>,
3558 group_id: u32,
3559 panel_name: String,
3560 entries_arr: Vec<rquickjs::Object<'js>>,
3561 ) -> rquickjs::Result<bool> {
3562 let entries: Vec<TextPropertyEntry> = entries_arr
3563 .iter()
3564 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3565 .collect();
3566 Ok(self
3567 .command_sender
3568 .send(PluginCommand::SetPanelContent {
3569 group_id: group_id as usize,
3570 panel_name,
3571 entries,
3572 })
3573 .is_ok())
3574 }
3575
3576 #[qjs(rename = "closeBufferGroup")]
3578 pub fn close_buffer_group(&self, group_id: u32) -> bool {
3579 self.command_sender
3580 .send(PluginCommand::CloseBufferGroup {
3581 group_id: group_id as usize,
3582 })
3583 .is_ok()
3584 }
3585
3586 #[qjs(rename = "focusBufferGroupPanel")]
3588 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
3589 self.command_sender
3590 .send(PluginCommand::FocusPanel {
3591 group_id: group_id as usize,
3592 panel_name,
3593 })
3594 .is_ok()
3595 }
3596
3597 pub fn set_virtual_buffer_content<'js>(
3601 &self,
3602 ctx: rquickjs::Ctx<'js>,
3603 buffer_id: u32,
3604 entries_arr: Vec<rquickjs::Object<'js>>,
3605 ) -> rquickjs::Result<bool> {
3606 let entries: Vec<TextPropertyEntry> = entries_arr
3607 .iter()
3608 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3609 .collect();
3610 Ok(self
3611 .command_sender
3612 .send(PluginCommand::SetVirtualBufferContent {
3613 buffer_id: BufferId(buffer_id as usize),
3614 entries,
3615 })
3616 .is_ok())
3617 }
3618
3619 pub fn get_text_properties_at_cursor(
3621 &self,
3622 buffer_id: u32,
3623 ) -> fresh_core::api::TextPropertiesAtCursor {
3624 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3625 }
3626
3627 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3631 #[qjs(rename = "_spawnProcessStart")]
3632 pub fn spawn_process_start(
3633 &self,
3634 _ctx: rquickjs::Ctx<'_>,
3635 command: String,
3636 args: Vec<String>,
3637 cwd: rquickjs::function::Opt<String>,
3638 ) -> u64 {
3639 let id = {
3640 let mut id_ref = self.next_request_id.borrow_mut();
3641 let id = *id_ref;
3642 *id_ref += 1;
3643 self.callback_contexts
3645 .borrow_mut()
3646 .insert(id, self.plugin_name.clone());
3647 id
3648 };
3649 let effective_cwd = cwd.0.or_else(|| {
3651 self.state_snapshot
3652 .read()
3653 .ok()
3654 .map(|s| s.working_dir.to_string_lossy().to_string())
3655 });
3656 tracing::info!(
3657 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3658 self.plugin_name,
3659 command,
3660 args,
3661 effective_cwd,
3662 id
3663 );
3664 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3665 callback_id: JsCallbackId::new(id),
3666 command,
3667 args,
3668 cwd: effective_cwd,
3669 });
3670 id
3671 }
3672
3673 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3675 #[qjs(rename = "_spawnProcessWaitStart")]
3676 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3677 let id = {
3678 let mut id_ref = self.next_request_id.borrow_mut();
3679 let id = *id_ref;
3680 *id_ref += 1;
3681 self.callback_contexts
3683 .borrow_mut()
3684 .insert(id, self.plugin_name.clone());
3685 id
3686 };
3687 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3688 process_id,
3689 callback_id: JsCallbackId::new(id),
3690 });
3691 id
3692 }
3693
3694 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3696 #[qjs(rename = "_getBufferTextStart")]
3697 pub fn get_buffer_text_start(
3698 &self,
3699 _ctx: rquickjs::Ctx<'_>,
3700 buffer_id: u32,
3701 start: u32,
3702 end: u32,
3703 ) -> u64 {
3704 let id = {
3705 let mut id_ref = self.next_request_id.borrow_mut();
3706 let id = *id_ref;
3707 *id_ref += 1;
3708 self.callback_contexts
3710 .borrow_mut()
3711 .insert(id, self.plugin_name.clone());
3712 id
3713 };
3714 let _ = self.command_sender.send(PluginCommand::GetBufferText {
3715 buffer_id: BufferId(buffer_id as usize),
3716 start: start as usize,
3717 end: end as usize,
3718 request_id: id,
3719 });
3720 id
3721 }
3722
3723 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3725 #[qjs(rename = "_delayStart")]
3726 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3727 let id = {
3728 let mut id_ref = self.next_request_id.borrow_mut();
3729 let id = *id_ref;
3730 *id_ref += 1;
3731 self.callback_contexts
3733 .borrow_mut()
3734 .insert(id, self.plugin_name.clone());
3735 id
3736 };
3737 let _ = self.command_sender.send(PluginCommand::Delay {
3738 callback_id: JsCallbackId::new(id),
3739 duration_ms,
3740 });
3741 id
3742 }
3743
3744 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
3748 #[qjs(rename = "_grepProjectStart")]
3749 pub fn grep_project_start(
3750 &self,
3751 _ctx: rquickjs::Ctx<'_>,
3752 pattern: String,
3753 fixed_string: Option<bool>,
3754 case_sensitive: Option<bool>,
3755 max_results: Option<u32>,
3756 whole_words: Option<bool>,
3757 ) -> u64 {
3758 let id = {
3759 let mut id_ref = self.next_request_id.borrow_mut();
3760 let id = *id_ref;
3761 *id_ref += 1;
3762 self.callback_contexts
3763 .borrow_mut()
3764 .insert(id, self.plugin_name.clone());
3765 id
3766 };
3767 let _ = self.command_sender.send(PluginCommand::GrepProject {
3768 pattern,
3769 fixed_string: fixed_string.unwrap_or(true),
3770 case_sensitive: case_sensitive.unwrap_or(true),
3771 max_results: max_results.unwrap_or(200) as usize,
3772 whole_words: whole_words.unwrap_or(false),
3773 callback_id: JsCallbackId::new(id),
3774 });
3775 id
3776 }
3777
3778 #[plugin_api(
3782 js_name = "grepProjectStreaming",
3783 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
3784 )]
3785 #[qjs(rename = "_grepProjectStreamingStart")]
3786 pub fn grep_project_streaming_start(
3787 &self,
3788 _ctx: rquickjs::Ctx<'_>,
3789 pattern: String,
3790 fixed_string: bool,
3791 case_sensitive: bool,
3792 max_results: u32,
3793 whole_words: bool,
3794 ) -> u64 {
3795 let id = {
3796 let mut id_ref = self.next_request_id.borrow_mut();
3797 let id = *id_ref;
3798 *id_ref += 1;
3799 self.callback_contexts
3800 .borrow_mut()
3801 .insert(id, self.plugin_name.clone());
3802 id
3803 };
3804 let _ = self
3805 .command_sender
3806 .send(PluginCommand::GrepProjectStreaming {
3807 pattern,
3808 fixed_string,
3809 case_sensitive,
3810 max_results: max_results as usize,
3811 whole_words,
3812 search_id: id,
3813 callback_id: JsCallbackId::new(id),
3814 });
3815 id
3816 }
3817
3818 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
3822 #[qjs(rename = "_replaceInFileStart")]
3823 pub fn replace_in_file_start(
3824 &self,
3825 _ctx: rquickjs::Ctx<'_>,
3826 file_path: String,
3827 matches: Vec<Vec<u32>>,
3828 replacement: String,
3829 ) -> u64 {
3830 let id = {
3831 let mut id_ref = self.next_request_id.borrow_mut();
3832 let id = *id_ref;
3833 *id_ref += 1;
3834 self.callback_contexts
3835 .borrow_mut()
3836 .insert(id, self.plugin_name.clone());
3837 id
3838 };
3839 let match_pairs: Vec<(usize, usize)> = matches
3841 .iter()
3842 .map(|m| (m[0] as usize, m[1] as usize))
3843 .collect();
3844 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
3845 file_path: PathBuf::from(file_path),
3846 matches: match_pairs,
3847 replacement,
3848 callback_id: JsCallbackId::new(id),
3849 });
3850 id
3851 }
3852
3853 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3855 #[qjs(rename = "_sendLspRequestStart")]
3856 pub fn send_lsp_request_start<'js>(
3857 &self,
3858 ctx: rquickjs::Ctx<'js>,
3859 language: String,
3860 method: String,
3861 params: Option<rquickjs::Object<'js>>,
3862 ) -> rquickjs::Result<u64> {
3863 let id = {
3864 let mut id_ref = self.next_request_id.borrow_mut();
3865 let id = *id_ref;
3866 *id_ref += 1;
3867 self.callback_contexts
3869 .borrow_mut()
3870 .insert(id, self.plugin_name.clone());
3871 id
3872 };
3873 let params_json: Option<serde_json::Value> = params.map(|obj| {
3875 let val = obj.into_value();
3876 js_to_json(&ctx, val)
3877 });
3878 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3879 request_id: id,
3880 language,
3881 method,
3882 params: params_json,
3883 });
3884 Ok(id)
3885 }
3886
3887 #[plugin_api(
3889 async_thenable,
3890 js_name = "spawnBackgroundProcess",
3891 ts_return = "BackgroundProcessResult"
3892 )]
3893 #[qjs(rename = "_spawnBackgroundProcessStart")]
3894 pub fn spawn_background_process_start(
3895 &self,
3896 _ctx: rquickjs::Ctx<'_>,
3897 command: String,
3898 args: Vec<String>,
3899 cwd: rquickjs::function::Opt<String>,
3900 ) -> u64 {
3901 let id = {
3902 let mut id_ref = self.next_request_id.borrow_mut();
3903 let id = *id_ref;
3904 *id_ref += 1;
3905 self.callback_contexts
3907 .borrow_mut()
3908 .insert(id, self.plugin_name.clone());
3909 id
3910 };
3911 let process_id = id;
3913 self.plugin_tracked_state
3915 .borrow_mut()
3916 .entry(self.plugin_name.clone())
3917 .or_default()
3918 .background_process_ids
3919 .push(process_id);
3920 let _ = self
3921 .command_sender
3922 .send(PluginCommand::SpawnBackgroundProcess {
3923 process_id,
3924 command,
3925 args,
3926 cwd: cwd.0,
3927 callback_id: JsCallbackId::new(id),
3928 });
3929 id
3930 }
3931
3932 pub fn kill_background_process(&self, process_id: u64) -> bool {
3934 self.command_sender
3935 .send(PluginCommand::KillBackgroundProcess { process_id })
3936 .is_ok()
3937 }
3938
3939 #[plugin_api(
3943 async_promise,
3944 js_name = "createTerminal",
3945 ts_return = "TerminalResult"
3946 )]
3947 #[qjs(rename = "_createTerminalStart")]
3948 pub fn create_terminal_start(
3949 &self,
3950 _ctx: rquickjs::Ctx<'_>,
3951 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3952 ) -> rquickjs::Result<u64> {
3953 let id = {
3954 let mut id_ref = self.next_request_id.borrow_mut();
3955 let id = *id_ref;
3956 *id_ref += 1;
3957 self.callback_contexts
3958 .borrow_mut()
3959 .insert(id, self.plugin_name.clone());
3960 id
3961 };
3962
3963 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3964 cwd: None,
3965 direction: None,
3966 ratio: None,
3967 focus: None,
3968 });
3969
3970 if let Ok(mut owners) = self.async_resource_owners.lock() {
3972 owners.insert(id, self.plugin_name.clone());
3973 }
3974 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3975 cwd: opts.cwd,
3976 direction: opts.direction,
3977 ratio: opts.ratio,
3978 focus: opts.focus,
3979 request_id: id,
3980 });
3981 Ok(id)
3982 }
3983
3984 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3986 self.command_sender
3987 .send(PluginCommand::SendTerminalInput {
3988 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3989 data,
3990 })
3991 .is_ok()
3992 }
3993
3994 pub fn close_terminal(&self, terminal_id: u64) -> bool {
3996 self.command_sender
3997 .send(PluginCommand::CloseTerminal {
3998 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3999 })
4000 .is_ok()
4001 }
4002
4003 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
4007 self.command_sender
4008 .send(PluginCommand::RefreshLines {
4009 buffer_id: BufferId(buffer_id as usize),
4010 })
4011 .is_ok()
4012 }
4013
4014 pub fn get_current_locale(&self) -> String {
4016 self.services.current_locale()
4017 }
4018
4019 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
4023 #[qjs(rename = "_loadPluginStart")]
4024 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
4025 let id = {
4026 let mut id_ref = self.next_request_id.borrow_mut();
4027 let id = *id_ref;
4028 *id_ref += 1;
4029 self.callback_contexts
4030 .borrow_mut()
4031 .insert(id, self.plugin_name.clone());
4032 id
4033 };
4034 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
4035 path: std::path::PathBuf::from(path),
4036 callback_id: JsCallbackId::new(id),
4037 });
4038 id
4039 }
4040
4041 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
4043 #[qjs(rename = "_unloadPluginStart")]
4044 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4045 let id = {
4046 let mut id_ref = self.next_request_id.borrow_mut();
4047 let id = *id_ref;
4048 *id_ref += 1;
4049 self.callback_contexts
4050 .borrow_mut()
4051 .insert(id, self.plugin_name.clone());
4052 id
4053 };
4054 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
4055 name,
4056 callback_id: JsCallbackId::new(id),
4057 });
4058 id
4059 }
4060
4061 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
4063 #[qjs(rename = "_reloadPluginStart")]
4064 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4065 let id = {
4066 let mut id_ref = self.next_request_id.borrow_mut();
4067 let id = *id_ref;
4068 *id_ref += 1;
4069 self.callback_contexts
4070 .borrow_mut()
4071 .insert(id, self.plugin_name.clone());
4072 id
4073 };
4074 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
4075 name,
4076 callback_id: JsCallbackId::new(id),
4077 });
4078 id
4079 }
4080
4081 #[plugin_api(
4084 async_promise,
4085 js_name = "listPlugins",
4086 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
4087 )]
4088 #[qjs(rename = "_listPluginsStart")]
4089 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4090 let id = {
4091 let mut id_ref = self.next_request_id.borrow_mut();
4092 let id = *id_ref;
4093 *id_ref += 1;
4094 self.callback_contexts
4095 .borrow_mut()
4096 .insert(id, self.plugin_name.clone());
4097 id
4098 };
4099 let _ = self.command_sender.send(PluginCommand::ListPlugins {
4100 callback_id: JsCallbackId::new(id),
4101 });
4102 id
4103 }
4104}
4105
4106fn parse_view_token(
4113 obj: &rquickjs::Object<'_>,
4114 idx: usize,
4115) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
4116 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4117
4118 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
4120 from: "object",
4121 to: "ViewTokenWire",
4122 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
4123 })?;
4124
4125 let source_offset: Option<usize> = obj
4127 .get("sourceOffset")
4128 .ok()
4129 .or_else(|| obj.get("source_offset").ok());
4130
4131 let kind = if kind_value.is_string() {
4133 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4136 from: "value",
4137 to: "string",
4138 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
4139 })?;
4140
4141 match kind_str.to_lowercase().as_str() {
4142 "text" => {
4143 let text: String = obj.get("text").unwrap_or_default();
4144 ViewTokenWireKind::Text(text)
4145 }
4146 "newline" => ViewTokenWireKind::Newline,
4147 "space" => ViewTokenWireKind::Space,
4148 "break" => ViewTokenWireKind::Break,
4149 _ => {
4150 tracing::warn!(
4152 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
4153 idx, kind_str
4154 );
4155 return Err(rquickjs::Error::FromJs {
4156 from: "string",
4157 to: "ViewTokenWireKind",
4158 message: Some(format!(
4159 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
4160 idx, kind_str
4161 )),
4162 });
4163 }
4164 }
4165 } else if kind_value.is_object() {
4166 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4168 from: "value",
4169 to: "object",
4170 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
4171 })?;
4172
4173 if let Ok(text) = kind_obj.get::<_, String>("Text") {
4174 ViewTokenWireKind::Text(text)
4175 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
4176 ViewTokenWireKind::BinaryByte(byte)
4177 } else {
4178 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
4180 tracing::warn!(
4181 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
4182 idx,
4183 keys
4184 );
4185 return Err(rquickjs::Error::FromJs {
4186 from: "object",
4187 to: "ViewTokenWireKind",
4188 message: Some(format!(
4189 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
4190 idx, keys
4191 )),
4192 });
4193 }
4194 } else {
4195 tracing::warn!(
4196 "token[{}]: 'kind' field must be a string or object, got: {:?}",
4197 idx,
4198 kind_value.type_of()
4199 );
4200 return Err(rquickjs::Error::FromJs {
4201 from: "value",
4202 to: "ViewTokenWireKind",
4203 message: Some(format!(
4204 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
4205 idx
4206 )),
4207 });
4208 };
4209
4210 let style = parse_view_token_style(obj, idx)?;
4212
4213 Ok(ViewTokenWire {
4214 source_offset,
4215 kind,
4216 style,
4217 })
4218}
4219
4220fn parse_view_token_style(
4222 obj: &rquickjs::Object<'_>,
4223 idx: usize,
4224) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
4225 use fresh_core::api::ViewTokenStyle;
4226
4227 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
4228 let Some(s) = style_obj else {
4229 return Ok(None);
4230 };
4231
4232 let fg: Option<Vec<u8>> = s.get("fg").ok();
4233 let bg: Option<Vec<u8>> = s.get("bg").ok();
4234
4235 let fg_color = if let Some(ref c) = fg {
4237 if c.len() < 3 {
4238 tracing::warn!(
4239 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4240 idx,
4241 c.len()
4242 );
4243 None
4244 } else {
4245 Some((c[0], c[1], c[2]))
4246 }
4247 } else {
4248 None
4249 };
4250
4251 let bg_color = if let Some(ref c) = bg {
4252 if c.len() < 3 {
4253 tracing::warn!(
4254 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4255 idx,
4256 c.len()
4257 );
4258 None
4259 } else {
4260 Some((c[0], c[1], c[2]))
4261 }
4262 } else {
4263 None
4264 };
4265
4266 Ok(Some(ViewTokenStyle {
4267 fg: fg_color,
4268 bg: bg_color,
4269 bold: s.get("bold").unwrap_or(false),
4270 italic: s.get("italic").unwrap_or(false),
4271 }))
4272}
4273
4274pub struct QuickJsBackend {
4276 runtime: Runtime,
4277 main_context: Context,
4279 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4281 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4283 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4285 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4287 command_sender: mpsc::Sender<PluginCommand>,
4289 #[allow(dead_code)]
4291 pending_responses: PendingResponses,
4292 next_request_id: Rc<RefCell<u64>>,
4294 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4296 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4298 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4300 async_resource_owners: AsyncResourceOwners,
4303 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4305 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4307 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4309 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4311}
4312
4313impl QuickJsBackend {
4314 pub fn new() -> Result<Self> {
4316 let (tx, _rx) = mpsc::channel();
4317 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4318 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4319 Self::with_state(state_snapshot, tx, services)
4320 }
4321
4322 pub fn with_state(
4324 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4325 command_sender: mpsc::Sender<PluginCommand>,
4326 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4327 ) -> Result<Self> {
4328 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4329 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4330 }
4331
4332 pub fn with_state_and_responses(
4334 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4335 command_sender: mpsc::Sender<PluginCommand>,
4336 pending_responses: PendingResponses,
4337 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4338 ) -> Result<Self> {
4339 let async_resource_owners: AsyncResourceOwners =
4340 Arc::new(std::sync::Mutex::new(HashMap::new()));
4341 Self::with_state_responses_and_resources(
4342 state_snapshot,
4343 command_sender,
4344 pending_responses,
4345 services,
4346 async_resource_owners,
4347 )
4348 }
4349
4350 pub fn with_state_responses_and_resources(
4353 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4354 command_sender: mpsc::Sender<PluginCommand>,
4355 pending_responses: PendingResponses,
4356 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4357 async_resource_owners: AsyncResourceOwners,
4358 ) -> Result<Self> {
4359 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4360
4361 let runtime =
4362 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4363
4364 runtime.set_host_promise_rejection_tracker(Some(Box::new(
4366 |_ctx, _promise, reason, is_handled| {
4367 if !is_handled {
4368 let error_msg = if let Some(exc) = reason.as_exception() {
4370 format!(
4371 "{}: {}",
4372 exc.message().unwrap_or_default(),
4373 exc.stack().unwrap_or_default()
4374 )
4375 } else {
4376 format!("{:?}", reason)
4377 };
4378
4379 tracing::error!("Unhandled Promise rejection: {}", error_msg);
4380
4381 if should_panic_on_js_errors() {
4382 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4385 set_fatal_js_error(full_msg);
4386 }
4387 }
4388 },
4389 )));
4390
4391 let main_context = Context::full(&runtime)
4392 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4393
4394 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4395 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4396 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4397 let next_request_id = Rc::new(RefCell::new(1u64));
4398 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4399 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4400 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4401 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4402 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4403 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4404
4405 let backend = Self {
4406 runtime,
4407 main_context,
4408 plugin_contexts,
4409 event_handlers,
4410 registered_actions,
4411 state_snapshot,
4412 command_sender,
4413 pending_responses,
4414 next_request_id,
4415 callback_contexts,
4416 services,
4417 plugin_tracked_state,
4418 async_resource_owners,
4419 registered_command_names,
4420 registered_grammar_languages,
4421 registered_language_configs,
4422 registered_lsp_servers,
4423 };
4424
4425 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4427
4428 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4429 Ok(backend)
4430 }
4431
4432 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4434 let state_snapshot = Arc::clone(&self.state_snapshot);
4435 let command_sender = self.command_sender.clone();
4436 let event_handlers = Rc::clone(&self.event_handlers);
4437 let registered_actions = Rc::clone(&self.registered_actions);
4438 let next_request_id = Rc::clone(&self.next_request_id);
4439 let registered_command_names = Rc::clone(&self.registered_command_names);
4440 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4441 let registered_language_configs = Rc::clone(&self.registered_language_configs);
4442 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4443
4444 context.with(|ctx| {
4445 let globals = ctx.globals();
4446
4447 globals.set("__pluginName__", plugin_name)?;
4449
4450 let js_api = JsEditorApi {
4453 state_snapshot: Arc::clone(&state_snapshot),
4454 command_sender: command_sender.clone(),
4455 registered_actions: Rc::clone(®istered_actions),
4456 event_handlers: Rc::clone(&event_handlers),
4457 next_request_id: Rc::clone(&next_request_id),
4458 callback_contexts: Rc::clone(&self.callback_contexts),
4459 services: self.services.clone(),
4460 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4461 async_resource_owners: Arc::clone(&self.async_resource_owners),
4462 registered_command_names: Rc::clone(®istered_command_names),
4463 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
4464 registered_language_configs: Rc::clone(®istered_language_configs),
4465 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
4466 plugin_name: plugin_name.to_string(),
4467 };
4468 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4469
4470 globals.set("editor", editor)?;
4472
4473 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4475
4476 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4478
4479 let console = Object::new(ctx.clone())?;
4482 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4483 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4484 tracing::info!("console.log: {}", parts.join(" "));
4485 })?)?;
4486 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4487 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4488 tracing::warn!("console.warn: {}", parts.join(" "));
4489 })?)?;
4490 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4491 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4492 tracing::error!("console.error: {}", parts.join(" "));
4493 })?)?;
4494 globals.set("console", console)?;
4495
4496 ctx.eval::<(), _>(r#"
4498 // Pending promise callbacks: callbackId -> { resolve, reject }
4499 globalThis._pendingCallbacks = new Map();
4500
4501 // Resolve a pending callback (called from Rust)
4502 globalThis._resolveCallback = function(callbackId, result) {
4503 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4504 const cb = globalThis._pendingCallbacks.get(callbackId);
4505 if (cb) {
4506 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4507 globalThis._pendingCallbacks.delete(callbackId);
4508 cb.resolve(result);
4509 console.log('[JS] _resolveCallback: resolve() called');
4510 } else {
4511 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4512 }
4513 };
4514
4515 // Reject a pending callback (called from Rust)
4516 globalThis._rejectCallback = function(callbackId, error) {
4517 const cb = globalThis._pendingCallbacks.get(callbackId);
4518 if (cb) {
4519 globalThis._pendingCallbacks.delete(callbackId);
4520 cb.reject(new Error(error));
4521 }
4522 };
4523
4524 // Streaming callbacks: called multiple times with partial results
4525 globalThis._streamingCallbacks = new Map();
4526
4527 // Called from Rust with partial data. When done=true, cleans up.
4528 globalThis._callStreamingCallback = function(callbackId, result, done) {
4529 const cb = globalThis._streamingCallbacks.get(callbackId);
4530 if (cb) {
4531 cb(result, done);
4532 if (done) {
4533 globalThis._streamingCallbacks.delete(callbackId);
4534 }
4535 }
4536 };
4537
4538 // Generic async wrapper decorator
4539 // Wraps a function that returns a callbackId into a promise-returning function
4540 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4541 // NOTE: We pass the method name as a string and call via bracket notation
4542 // to preserve rquickjs's automatic Ctx injection for methods
4543 globalThis._wrapAsync = function(methodName, fnName) {
4544 const startFn = editor[methodName];
4545 if (typeof startFn !== 'function') {
4546 // Return a function that always throws - catches missing implementations
4547 return function(...args) {
4548 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4549 editor.debug(`[ASYNC ERROR] ${error.message}`);
4550 throw error;
4551 };
4552 }
4553 return function(...args) {
4554 // Call via bracket notation to preserve method binding and Ctx injection
4555 const callbackId = editor[methodName](...args);
4556 return new Promise((resolve, reject) => {
4557 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4558 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4559 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4560 });
4561 };
4562 };
4563
4564 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4565 // The returned object has .result promise and is itself thenable
4566 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4567 const startFn = editor[methodName];
4568 if (typeof startFn !== 'function') {
4569 // Return a function that always throws - catches missing implementations
4570 return function(...args) {
4571 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4572 editor.debug(`[ASYNC ERROR] ${error.message}`);
4573 throw error;
4574 };
4575 }
4576 return function(...args) {
4577 // Call via bracket notation to preserve method binding and Ctx injection
4578 const callbackId = editor[methodName](...args);
4579 const resultPromise = new Promise((resolve, reject) => {
4580 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4581 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4582 });
4583 return {
4584 get result() { return resultPromise; },
4585 then(onFulfilled, onRejected) {
4586 return resultPromise.then(onFulfilled, onRejected);
4587 },
4588 catch(onRejected) {
4589 return resultPromise.catch(onRejected);
4590 }
4591 };
4592 };
4593 };
4594
4595 // Apply wrappers to async functions on editor
4596 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
4597 editor.delay = _wrapAsync("_delayStart", "delay");
4598 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
4599 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
4600 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
4601 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
4602 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
4603 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
4604 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
4605 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
4606 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
4607 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
4608 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
4609 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
4610 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
4611 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
4612 editor.prompt = _wrapAsync("_promptStart", "prompt");
4613 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
4614 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
4615 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
4616 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
4617 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
4618 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
4619
4620 // Streaming grep: takes a progress callback, returns a thenable with searchId
4621 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
4622 opts = opts || {};
4623 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
4624 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
4625 const maxResults = opts.maxResults || 10000;
4626 const wholeWords = opts.wholeWords || false;
4627
4628 const searchId = editor._grepProjectStreamingStart(
4629 pattern, fixedString, caseSensitive, maxResults, wholeWords
4630 );
4631
4632 // Register streaming callback
4633 if (progressCallback) {
4634 globalThis._streamingCallbacks.set(searchId, progressCallback);
4635 }
4636
4637 // Create completion promise (resolved via _resolveCallback when search finishes)
4638 const resultPromise = new Promise(function(resolve, reject) {
4639 globalThis._pendingCallbacks.set(searchId, {
4640 resolve: function(result) {
4641 globalThis._streamingCallbacks.delete(searchId);
4642 resolve(result);
4643 },
4644 reject: function(err) {
4645 globalThis._streamingCallbacks.delete(searchId);
4646 reject(err);
4647 }
4648 });
4649 });
4650
4651 return {
4652 searchId: searchId,
4653 get result() { return resultPromise; },
4654 then: function(f, r) { return resultPromise.then(f, r); },
4655 catch: function(r) { return resultPromise.catch(r); }
4656 };
4657 };
4658
4659 // Wrapper for deleteTheme - wraps sync function in Promise
4660 editor.deleteTheme = function(name) {
4661 return new Promise(function(resolve, reject) {
4662 const success = editor._deleteThemeSync(name);
4663 if (success) {
4664 resolve();
4665 } else {
4666 reject(new Error("Failed to delete theme: " + name));
4667 }
4668 });
4669 };
4670 "#.as_bytes())?;
4671
4672 Ok::<_, rquickjs::Error>(())
4673 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
4674
4675 Ok(())
4676 }
4677
4678 pub async fn load_module_with_source(
4680 &mut self,
4681 path: &str,
4682 _plugin_source: &str,
4683 ) -> Result<()> {
4684 let path_buf = PathBuf::from(path);
4685 let source = std::fs::read_to_string(&path_buf)
4686 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
4687
4688 let filename = path_buf
4689 .file_name()
4690 .and_then(|s| s.to_str())
4691 .unwrap_or("plugin.ts");
4692
4693 if has_es_imports(&source) {
4695 match bundle_module(&path_buf) {
4697 Ok(bundled) => {
4698 self.execute_js(&bundled, path)?;
4699 }
4700 Err(e) => {
4701 tracing::warn!(
4702 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
4703 path,
4704 e
4705 );
4706 return Ok(()); }
4708 }
4709 } else if has_es_module_syntax(&source) {
4710 let stripped = strip_imports_and_exports(&source);
4712 let js_code = if filename.ends_with(".ts") {
4713 transpile_typescript(&stripped, filename)?
4714 } else {
4715 stripped
4716 };
4717 self.execute_js(&js_code, path)?;
4718 } else {
4719 let js_code = if filename.ends_with(".ts") {
4721 transpile_typescript(&source, filename)?
4722 } else {
4723 source
4724 };
4725 self.execute_js(&js_code, path)?;
4726 }
4727
4728 Ok(())
4729 }
4730
4731 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
4733 let plugin_name = Path::new(source_name)
4735 .file_stem()
4736 .and_then(|s| s.to_str())
4737 .unwrap_or("unknown");
4738
4739 tracing::debug!(
4740 "execute_js: starting for plugin '{}' from '{}'",
4741 plugin_name,
4742 source_name
4743 );
4744
4745 let context = {
4747 let mut contexts = self.plugin_contexts.borrow_mut();
4748 if let Some(ctx) = contexts.get(plugin_name) {
4749 ctx.clone()
4750 } else {
4751 let ctx = Context::full(&self.runtime).map_err(|e| {
4752 anyhow!(
4753 "Failed to create QuickJS context for plugin {}: {}",
4754 plugin_name,
4755 e
4756 )
4757 })?;
4758 self.setup_context_api(&ctx, plugin_name)?;
4759 contexts.insert(plugin_name.to_string(), ctx.clone());
4760 ctx
4761 }
4762 };
4763
4764 let wrapped_code = format!("(function() {{ {} }})();", code);
4768 let wrapped = wrapped_code.as_str();
4769
4770 context.with(|ctx| {
4771 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
4772
4773 let mut eval_options = rquickjs::context::EvalOptions::default();
4775 eval_options.global = true;
4776 eval_options.filename = Some(source_name.to_string());
4777 let result = ctx
4778 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
4779 .map_err(|e| format_js_error(&ctx, e, source_name));
4780
4781 tracing::debug!(
4782 "execute_js: plugin code execution finished for '{}', result: {:?}",
4783 plugin_name,
4784 result.is_ok()
4785 );
4786
4787 result
4788 })
4789 }
4790
4791 pub fn execute_source(
4797 &mut self,
4798 source: &str,
4799 plugin_name: &str,
4800 is_typescript: bool,
4801 ) -> Result<()> {
4802 use fresh_parser_js::{
4803 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4804 };
4805
4806 if has_es_imports(source) {
4807 tracing::warn!(
4808 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4809 plugin_name
4810 );
4811 }
4812
4813 let js_code = if has_es_module_syntax(source) {
4814 let stripped = strip_imports_and_exports(source);
4815 if is_typescript {
4816 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4817 } else {
4818 stripped
4819 }
4820 } else if is_typescript {
4821 transpile_typescript(source, &format!("{}.ts", plugin_name))?
4822 } else {
4823 source.to_string()
4824 };
4825
4826 let source_name = format!(
4828 "{}.{}",
4829 plugin_name,
4830 if is_typescript { "ts" } else { "js" }
4831 );
4832 self.execute_js(&js_code, &source_name)
4833 }
4834
4835 pub fn cleanup_plugin(&self, plugin_name: &str) {
4841 self.plugin_contexts.borrow_mut().remove(plugin_name);
4843
4844 for handlers in self.event_handlers.borrow_mut().values_mut() {
4846 handlers.retain(|h| h.plugin_name != plugin_name);
4847 }
4848
4849 self.registered_actions
4851 .borrow_mut()
4852 .retain(|_, h| h.plugin_name != plugin_name);
4853
4854 self.callback_contexts
4856 .borrow_mut()
4857 .retain(|_, pname| pname != plugin_name);
4858
4859 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4861 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4863 std::collections::HashSet::new();
4864 for (buf_id, ns) in &tracked.overlay_namespaces {
4865 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4866 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4868 buffer_id: *buf_id,
4869 namespace: OverlayNamespace::from_string(ns.clone()),
4870 });
4871 let _ = self
4873 .command_sender
4874 .send(PluginCommand::ClearConcealNamespace {
4875 buffer_id: *buf_id,
4876 namespace: OverlayNamespace::from_string(ns.clone()),
4877 });
4878 let _ = self
4879 .command_sender
4880 .send(PluginCommand::ClearSoftBreakNamespace {
4881 buffer_id: *buf_id,
4882 namespace: OverlayNamespace::from_string(ns.clone()),
4883 });
4884 }
4885 }
4886
4887 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4893 std::collections::HashSet::new();
4894 for (buf_id, ns) in &tracked.line_indicator_namespaces {
4895 if seen_li_ns.insert((buf_id.0, ns.clone())) {
4896 let _ = self
4897 .command_sender
4898 .send(PluginCommand::ClearLineIndicators {
4899 buffer_id: *buf_id,
4900 namespace: ns.clone(),
4901 });
4902 }
4903 }
4904
4905 let mut seen_vt: std::collections::HashSet<(usize, String)> =
4907 std::collections::HashSet::new();
4908 for (buf_id, vt_id) in &tracked.virtual_text_ids {
4909 if seen_vt.insert((buf_id.0, vt_id.clone())) {
4910 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4911 buffer_id: *buf_id,
4912 virtual_text_id: vt_id.clone(),
4913 });
4914 }
4915 }
4916
4917 let mut seen_fe_ns: std::collections::HashSet<String> =
4919 std::collections::HashSet::new();
4920 for ns in &tracked.file_explorer_namespaces {
4921 if seen_fe_ns.insert(ns.clone()) {
4922 let _ = self
4923 .command_sender
4924 .send(PluginCommand::ClearFileExplorerDecorations {
4925 namespace: ns.clone(),
4926 });
4927 }
4928 }
4929
4930 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4932 for ctx_name in &tracked.contexts_set {
4933 if seen_ctx.insert(ctx_name.clone()) {
4934 let _ = self.command_sender.send(PluginCommand::SetContext {
4935 name: ctx_name.clone(),
4936 active: false,
4937 });
4938 }
4939 }
4940
4941 for process_id in &tracked.background_process_ids {
4945 let _ = self
4946 .command_sender
4947 .send(PluginCommand::KillBackgroundProcess {
4948 process_id: *process_id,
4949 });
4950 }
4951
4952 for group_id in &tracked.scroll_sync_group_ids {
4954 let _ = self
4955 .command_sender
4956 .send(PluginCommand::RemoveScrollSyncGroup {
4957 group_id: *group_id,
4958 });
4959 }
4960
4961 for buffer_id in &tracked.virtual_buffer_ids {
4963 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4964 buffer_id: *buffer_id,
4965 });
4966 }
4967
4968 for buffer_id in &tracked.composite_buffer_ids {
4970 let _ = self
4971 .command_sender
4972 .send(PluginCommand::CloseCompositeBuffer {
4973 buffer_id: *buffer_id,
4974 });
4975 }
4976
4977 for terminal_id in &tracked.terminal_ids {
4979 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4980 terminal_id: *terminal_id,
4981 });
4982 }
4983 }
4984
4985 if let Ok(mut owners) = self.async_resource_owners.lock() {
4987 owners.retain(|_, name| name != plugin_name);
4988 }
4989
4990 self.registered_command_names
4992 .borrow_mut()
4993 .retain(|_, pname| pname != plugin_name);
4994 self.registered_grammar_languages
4995 .borrow_mut()
4996 .retain(|_, pname| pname != plugin_name);
4997 self.registered_language_configs
4998 .borrow_mut()
4999 .retain(|_, pname| pname != plugin_name);
5000 self.registered_lsp_servers
5001 .borrow_mut()
5002 .retain(|_, pname| pname != plugin_name);
5003
5004 tracing::debug!(
5005 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
5006 plugin_name
5007 );
5008 }
5009
5010 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
5012 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
5013
5014 self.services
5015 .set_js_execution_state(format!("hook '{}'", event_name));
5016
5017 let handlers = self.event_handlers.borrow().get(event_name).cloned();
5018 if let Some(handler_pairs) = handlers {
5019 let plugin_contexts = self.plugin_contexts.borrow();
5020 for handler in &handler_pairs {
5021 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
5022 continue;
5023 };
5024 context.with(|ctx| {
5025 call_handler(&ctx, &handler.handler_name, event_data);
5026 });
5027 }
5028 }
5029
5030 self.services.clear_js_execution_state();
5031 Ok(true)
5032 }
5033
5034 pub fn has_handlers(&self, event_name: &str) -> bool {
5036 self.event_handlers
5037 .borrow()
5038 .get(event_name)
5039 .map(|v| !v.is_empty())
5040 .unwrap_or(false)
5041 }
5042
5043 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
5047 let (lookup_name, text_input_char) =
5050 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
5051 ("mode_text_input", Some(ch.to_string()))
5052 } else {
5053 (action_name, None)
5054 };
5055
5056 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
5057 let (plugin_name, function_name) = match pair {
5058 Some(handler) => (handler.plugin_name, handler.handler_name),
5059 None => ("main".to_string(), lookup_name.to_string()),
5060 };
5061
5062 let plugin_contexts = self.plugin_contexts.borrow();
5063 let context = plugin_contexts
5064 .get(&plugin_name)
5065 .unwrap_or(&self.main_context);
5066
5067 self.services
5069 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
5070
5071 tracing::info!(
5072 "start_action: BEGIN '{}' -> function '{}'",
5073 action_name,
5074 function_name
5075 );
5076
5077 let call_args = if let Some(ref ch) = text_input_char {
5080 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
5081 format!("({{text:\"{}\"}})", escaped)
5082 } else {
5083 "()".to_string()
5084 };
5085
5086 let code = format!(
5087 r#"
5088 (function() {{
5089 console.log('[JS] start_action: calling {fn}');
5090 try {{
5091 if (typeof globalThis.{fn} === 'function') {{
5092 console.log('[JS] start_action: {fn} is a function, invoking...');
5093 globalThis.{fn}{args};
5094 console.log('[JS] start_action: {fn} invoked (may be async)');
5095 }} else {{
5096 console.error('[JS] Action {action} is not defined as a global function');
5097 }}
5098 }} catch (e) {{
5099 console.error('[JS] Action {action} error:', e);
5100 }}
5101 }})();
5102 "#,
5103 fn = function_name,
5104 action = action_name,
5105 args = call_args
5106 );
5107
5108 tracing::info!("start_action: evaluating JS code");
5109 context.with(|ctx| {
5110 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5111 log_js_error(&ctx, e, &format!("action {}", action_name));
5112 }
5113 tracing::info!("start_action: running pending microtasks");
5114 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
5116 tracing::info!("start_action: executed {} pending jobs", count);
5117 });
5118
5119 tracing::info!("start_action: END '{}'", action_name);
5120
5121 self.services.clear_js_execution_state();
5123
5124 Ok(())
5125 }
5126
5127 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
5129 let pair = self.registered_actions.borrow().get(action_name).cloned();
5131 let (plugin_name, function_name) = match pair {
5132 Some(handler) => (handler.plugin_name, handler.handler_name),
5133 None => ("main".to_string(), action_name.to_string()),
5134 };
5135
5136 let plugin_contexts = self.plugin_contexts.borrow();
5137 let context = plugin_contexts
5138 .get(&plugin_name)
5139 .unwrap_or(&self.main_context);
5140
5141 tracing::debug!(
5142 "execute_action: '{}' -> function '{}'",
5143 action_name,
5144 function_name
5145 );
5146
5147 let code = format!(
5150 r#"
5151 (async function() {{
5152 try {{
5153 if (typeof globalThis.{fn} === 'function') {{
5154 const result = globalThis.{fn}();
5155 // If it's a Promise, await it
5156 if (result && typeof result.then === 'function') {{
5157 await result;
5158 }}
5159 }} else {{
5160 console.error('Action {action} is not defined as a global function');
5161 }}
5162 }} catch (e) {{
5163 console.error('Action {action} error:', e);
5164 }}
5165 }})();
5166 "#,
5167 fn = function_name,
5168 action = action_name
5169 );
5170
5171 context.with(|ctx| {
5172 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5174 Ok(value) => {
5175 if value.is_object() {
5177 if let Some(obj) = value.as_object() {
5178 if obj.get::<_, rquickjs::Function>("then").is_ok() {
5180 run_pending_jobs_checked(
5183 &ctx,
5184 &format!("execute_action {} promise", action_name),
5185 );
5186 }
5187 }
5188 }
5189 }
5190 Err(e) => {
5191 log_js_error(&ctx, e, &format!("action {}", action_name));
5192 }
5193 }
5194 });
5195
5196 Ok(())
5197 }
5198
5199 pub fn poll_event_loop_once(&mut self) -> bool {
5201 let mut had_work = false;
5202
5203 self.main_context.with(|ctx| {
5205 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
5206 if count > 0 {
5207 had_work = true;
5208 }
5209 });
5210
5211 let contexts = self.plugin_contexts.borrow().clone();
5213 for (name, context) in contexts {
5214 context.with(|ctx| {
5215 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
5216 if count > 0 {
5217 had_work = true;
5218 }
5219 });
5220 }
5221 had_work
5222 }
5223
5224 pub fn send_status(&self, message: String) {
5226 let _ = self
5227 .command_sender
5228 .send(PluginCommand::SetStatus { message });
5229 }
5230
5231 pub fn send_hook_completed(&self, hook_name: String) {
5235 let _ = self
5236 .command_sender
5237 .send(PluginCommand::HookCompleted { hook_name });
5238 }
5239
5240 pub fn resolve_callback(
5245 &mut self,
5246 callback_id: fresh_core::api::JsCallbackId,
5247 result_json: &str,
5248 ) {
5249 let id = callback_id.as_u64();
5250 tracing::debug!("resolve_callback: starting for callback_id={}", id);
5251
5252 let plugin_name = {
5254 let mut contexts = self.callback_contexts.borrow_mut();
5255 contexts.remove(&id)
5256 };
5257
5258 let Some(name) = plugin_name else {
5259 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5260 return;
5261 };
5262
5263 let plugin_contexts = self.plugin_contexts.borrow();
5264 let Some(context) = plugin_contexts.get(&name) else {
5265 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5266 return;
5267 };
5268
5269 context.with(|ctx| {
5270 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5272 Ok(v) => v,
5273 Err(e) => {
5274 tracing::error!(
5275 "resolve_callback: failed to parse JSON for callback_id={}: {}",
5276 id,
5277 e
5278 );
5279 return;
5280 }
5281 };
5282
5283 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5285 Ok(v) => v,
5286 Err(e) => {
5287 tracing::error!(
5288 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5289 id,
5290 e
5291 );
5292 return;
5293 }
5294 };
5295
5296 let globals = ctx.globals();
5298 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5299 Ok(f) => f,
5300 Err(e) => {
5301 tracing::error!(
5302 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5303 id,
5304 e
5305 );
5306 return;
5307 }
5308 };
5309
5310 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5312 log_js_error(&ctx, e, &format!("resolving callback {}", id));
5313 }
5314
5315 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5317 tracing::info!(
5318 "resolve_callback: executed {} pending jobs for callback_id={}",
5319 job_count,
5320 id
5321 );
5322 });
5323 }
5324
5325 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5327 let id = callback_id.as_u64();
5328
5329 let plugin_name = {
5331 let mut contexts = self.callback_contexts.borrow_mut();
5332 contexts.remove(&id)
5333 };
5334
5335 let Some(name) = plugin_name else {
5336 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5337 return;
5338 };
5339
5340 let plugin_contexts = self.plugin_contexts.borrow();
5341 let Some(context) = plugin_contexts.get(&name) else {
5342 tracing::warn!("reject_callback: Context lost for plugin {}", name);
5343 return;
5344 };
5345
5346 context.with(|ctx| {
5347 let globals = ctx.globals();
5349 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5350 Ok(f) => f,
5351 Err(e) => {
5352 tracing::error!(
5353 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5354 id,
5355 e
5356 );
5357 return;
5358 }
5359 };
5360
5361 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5363 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5364 }
5365
5366 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5368 });
5369 }
5370
5371 pub fn call_streaming_callback(
5375 &mut self,
5376 callback_id: fresh_core::api::JsCallbackId,
5377 result_json: &str,
5378 done: bool,
5379 ) {
5380 let id = callback_id.as_u64();
5381
5382 let plugin_name = {
5384 let contexts = self.callback_contexts.borrow();
5385 contexts.get(&id).cloned()
5386 };
5387
5388 let Some(name) = plugin_name else {
5389 tracing::warn!(
5390 "call_streaming_callback: No plugin found for callback_id={}",
5391 id
5392 );
5393 return;
5394 };
5395
5396 if done {
5398 self.callback_contexts.borrow_mut().remove(&id);
5399 }
5400
5401 let plugin_contexts = self.plugin_contexts.borrow();
5402 let Some(context) = plugin_contexts.get(&name) else {
5403 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5404 return;
5405 };
5406
5407 context.with(|ctx| {
5408 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5409 Ok(v) => v,
5410 Err(e) => {
5411 tracing::error!(
5412 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5413 id,
5414 e
5415 );
5416 return;
5417 }
5418 };
5419
5420 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5421 Ok(v) => v,
5422 Err(e) => {
5423 tracing::error!(
5424 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5425 id,
5426 e
5427 );
5428 return;
5429 }
5430 };
5431
5432 let globals = ctx.globals();
5433 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5434 Ok(f) => f,
5435 Err(e) => {
5436 tracing::error!(
5437 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5438 id,
5439 e
5440 );
5441 return;
5442 }
5443 };
5444
5445 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5446 log_js_error(
5447 &ctx,
5448 e,
5449 &format!("calling streaming callback {}", id),
5450 );
5451 }
5452
5453 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5454 });
5455 }
5456}
5457
5458#[cfg(test)]
5459mod tests {
5460 use super::*;
5461 use fresh_core::api::{BufferInfo, CursorInfo};
5462 use std::sync::mpsc;
5463
5464 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5466 let (tx, rx) = mpsc::channel();
5467 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5468 let services = Arc::new(TestServiceBridge::new());
5469 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5470 (backend, rx)
5471 }
5472
5473 struct TestServiceBridge {
5474 en_strings: std::sync::Mutex<HashMap<String, String>>,
5475 }
5476
5477 impl TestServiceBridge {
5478 fn new() -> Self {
5479 Self {
5480 en_strings: std::sync::Mutex::new(HashMap::new()),
5481 }
5482 }
5483 }
5484
5485 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5486 fn as_any(&self) -> &dyn std::any::Any {
5487 self
5488 }
5489 fn translate(
5490 &self,
5491 _plugin_name: &str,
5492 key: &str,
5493 _args: &HashMap<String, String>,
5494 ) -> String {
5495 self.en_strings
5496 .lock()
5497 .unwrap()
5498 .get(key)
5499 .cloned()
5500 .unwrap_or_else(|| key.to_string())
5501 }
5502 fn current_locale(&self) -> String {
5503 "en".to_string()
5504 }
5505 fn set_js_execution_state(&self, _state: String) {}
5506 fn clear_js_execution_state(&self) {}
5507 fn get_theme_schema(&self) -> serde_json::Value {
5508 serde_json::json!({})
5509 }
5510 fn get_builtin_themes(&self) -> serde_json::Value {
5511 serde_json::json!([])
5512 }
5513 fn register_command(&self, _command: fresh_core::command::Command) {}
5514 fn unregister_command(&self, _name: &str) {}
5515 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5516 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5517 fn plugins_dir(&self) -> std::path::PathBuf {
5518 std::path::PathBuf::from("/tmp/plugins")
5519 }
5520 fn config_dir(&self) -> std::path::PathBuf {
5521 std::path::PathBuf::from("/tmp/config")
5522 }
5523 fn data_dir(&self) -> std::path::PathBuf {
5524 std::path::PathBuf::from("/tmp/data")
5525 }
5526 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
5527 None
5528 }
5529 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
5530 Err("not implemented in test".to_string())
5531 }
5532 fn theme_file_exists(&self, _name: &str) -> bool {
5533 false
5534 }
5535 }
5536
5537 #[test]
5538 fn test_quickjs_backend_creation() {
5539 let backend = QuickJsBackend::new();
5540 assert!(backend.is_ok());
5541 }
5542
5543 #[test]
5544 fn test_execute_simple_js() {
5545 let mut backend = QuickJsBackend::new().unwrap();
5546 let result = backend.execute_js("const x = 1 + 2;", "test.js");
5547 assert!(result.is_ok());
5548 }
5549
5550 #[test]
5551 fn test_event_handler_registration() {
5552 let backend = QuickJsBackend::new().unwrap();
5553
5554 assert!(!backend.has_handlers("test_event"));
5556
5557 backend
5559 .event_handlers
5560 .borrow_mut()
5561 .entry("test_event".to_string())
5562 .or_default()
5563 .push(PluginHandler {
5564 plugin_name: "test".to_string(),
5565 handler_name: "testHandler".to_string(),
5566 });
5567
5568 assert!(backend.has_handlers("test_event"));
5570 }
5571
5572 #[test]
5575 fn test_api_set_status() {
5576 let (mut backend, rx) = create_test_backend();
5577
5578 backend
5579 .execute_js(
5580 r#"
5581 const editor = getEditor();
5582 editor.setStatus("Hello from test");
5583 "#,
5584 "test.js",
5585 )
5586 .unwrap();
5587
5588 let cmd = rx.try_recv().unwrap();
5589 match cmd {
5590 PluginCommand::SetStatus { message } => {
5591 assert_eq!(message, "Hello from test");
5592 }
5593 _ => panic!("Expected SetStatus command, got {:?}", cmd),
5594 }
5595 }
5596
5597 #[test]
5598 fn test_api_register_command() {
5599 let (mut backend, rx) = create_test_backend();
5600
5601 backend
5602 .execute_js(
5603 r#"
5604 const editor = getEditor();
5605 globalThis.myTestHandler = function() { };
5606 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
5607 "#,
5608 "test_plugin.js",
5609 )
5610 .unwrap();
5611
5612 let cmd = rx.try_recv().unwrap();
5613 match cmd {
5614 PluginCommand::RegisterCommand { command } => {
5615 assert_eq!(command.name, "Test Command");
5616 assert_eq!(command.description, "A test command");
5617 assert_eq!(command.plugin_name, "test_plugin");
5619 }
5620 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
5621 }
5622 }
5623
5624 #[test]
5625 fn test_api_define_mode() {
5626 let (mut backend, rx) = create_test_backend();
5627
5628 backend
5629 .execute_js(
5630 r#"
5631 const editor = getEditor();
5632 editor.defineMode("test-mode", [
5633 ["a", "action_a"],
5634 ["b", "action_b"]
5635 ]);
5636 "#,
5637 "test.js",
5638 )
5639 .unwrap();
5640
5641 let cmd = rx.try_recv().unwrap();
5642 match cmd {
5643 PluginCommand::DefineMode {
5644 name,
5645 bindings,
5646 read_only,
5647 allow_text_input,
5648 inherit_normal_bindings,
5649 plugin_name,
5650 } => {
5651 assert_eq!(name, "test-mode");
5652 assert_eq!(bindings.len(), 2);
5653 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
5654 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
5655 assert!(!read_only);
5656 assert!(!allow_text_input);
5657 assert!(!inherit_normal_bindings);
5658 assert!(plugin_name.is_some());
5659 }
5660 _ => panic!("Expected DefineMode, got {:?}", cmd),
5661 }
5662 }
5663
5664 #[test]
5665 fn test_api_set_editor_mode() {
5666 let (mut backend, rx) = create_test_backend();
5667
5668 backend
5669 .execute_js(
5670 r#"
5671 const editor = getEditor();
5672 editor.setEditorMode("vi-normal");
5673 "#,
5674 "test.js",
5675 )
5676 .unwrap();
5677
5678 let cmd = rx.try_recv().unwrap();
5679 match cmd {
5680 PluginCommand::SetEditorMode { mode } => {
5681 assert_eq!(mode, Some("vi-normal".to_string()));
5682 }
5683 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
5684 }
5685 }
5686
5687 #[test]
5688 fn test_api_clear_editor_mode() {
5689 let (mut backend, rx) = create_test_backend();
5690
5691 backend
5692 .execute_js(
5693 r#"
5694 const editor = getEditor();
5695 editor.setEditorMode(null);
5696 "#,
5697 "test.js",
5698 )
5699 .unwrap();
5700
5701 let cmd = rx.try_recv().unwrap();
5702 match cmd {
5703 PluginCommand::SetEditorMode { mode } => {
5704 assert!(mode.is_none());
5705 }
5706 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
5707 }
5708 }
5709
5710 #[test]
5711 fn test_api_insert_at_cursor() {
5712 let (mut backend, rx) = create_test_backend();
5713
5714 backend
5715 .execute_js(
5716 r#"
5717 const editor = getEditor();
5718 editor.insertAtCursor("Hello, World!");
5719 "#,
5720 "test.js",
5721 )
5722 .unwrap();
5723
5724 let cmd = rx.try_recv().unwrap();
5725 match cmd {
5726 PluginCommand::InsertAtCursor { text } => {
5727 assert_eq!(text, "Hello, World!");
5728 }
5729 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
5730 }
5731 }
5732
5733 #[test]
5734 fn test_api_set_context() {
5735 let (mut backend, rx) = create_test_backend();
5736
5737 backend
5738 .execute_js(
5739 r#"
5740 const editor = getEditor();
5741 editor.setContext("myContext", true);
5742 "#,
5743 "test.js",
5744 )
5745 .unwrap();
5746
5747 let cmd = rx.try_recv().unwrap();
5748 match cmd {
5749 PluginCommand::SetContext { name, active } => {
5750 assert_eq!(name, "myContext");
5751 assert!(active);
5752 }
5753 _ => panic!("Expected SetContext, got {:?}", cmd),
5754 }
5755 }
5756
5757 #[tokio::test]
5758 async fn test_execute_action_sync_function() {
5759 let (mut backend, rx) = create_test_backend();
5760
5761 backend.registered_actions.borrow_mut().insert(
5763 "my_sync_action".to_string(),
5764 PluginHandler {
5765 plugin_name: "test".to_string(),
5766 handler_name: "my_sync_action".to_string(),
5767 },
5768 );
5769
5770 backend
5772 .execute_js(
5773 r#"
5774 const editor = getEditor();
5775 globalThis.my_sync_action = function() {
5776 editor.setStatus("sync action executed");
5777 };
5778 "#,
5779 "test.js",
5780 )
5781 .unwrap();
5782
5783 while rx.try_recv().is_ok() {}
5785
5786 backend.execute_action("my_sync_action").await.unwrap();
5788
5789 let cmd = rx.try_recv().unwrap();
5791 match cmd {
5792 PluginCommand::SetStatus { message } => {
5793 assert_eq!(message, "sync action executed");
5794 }
5795 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
5796 }
5797 }
5798
5799 #[tokio::test]
5800 async fn test_execute_action_async_function() {
5801 let (mut backend, rx) = create_test_backend();
5802
5803 backend.registered_actions.borrow_mut().insert(
5805 "my_async_action".to_string(),
5806 PluginHandler {
5807 plugin_name: "test".to_string(),
5808 handler_name: "my_async_action".to_string(),
5809 },
5810 );
5811
5812 backend
5814 .execute_js(
5815 r#"
5816 const editor = getEditor();
5817 globalThis.my_async_action = async function() {
5818 await Promise.resolve();
5819 editor.setStatus("async action executed");
5820 };
5821 "#,
5822 "test.js",
5823 )
5824 .unwrap();
5825
5826 while rx.try_recv().is_ok() {}
5828
5829 backend.execute_action("my_async_action").await.unwrap();
5831
5832 let cmd = rx.try_recv().unwrap();
5834 match cmd {
5835 PluginCommand::SetStatus { message } => {
5836 assert_eq!(message, "async action executed");
5837 }
5838 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
5839 }
5840 }
5841
5842 #[tokio::test]
5843 async fn test_execute_action_with_registered_handler() {
5844 let (mut backend, rx) = create_test_backend();
5845
5846 backend.registered_actions.borrow_mut().insert(
5848 "my_action".to_string(),
5849 PluginHandler {
5850 plugin_name: "test".to_string(),
5851 handler_name: "actual_handler_function".to_string(),
5852 },
5853 );
5854
5855 backend
5856 .execute_js(
5857 r#"
5858 const editor = getEditor();
5859 globalThis.actual_handler_function = function() {
5860 editor.setStatus("handler executed");
5861 };
5862 "#,
5863 "test.js",
5864 )
5865 .unwrap();
5866
5867 while rx.try_recv().is_ok() {}
5869
5870 backend.execute_action("my_action").await.unwrap();
5872
5873 let cmd = rx.try_recv().unwrap();
5874 match cmd {
5875 PluginCommand::SetStatus { message } => {
5876 assert_eq!(message, "handler executed");
5877 }
5878 _ => panic!("Expected SetStatus, got {:?}", cmd),
5879 }
5880 }
5881
5882 #[test]
5883 fn test_api_on_event_registration() {
5884 let (mut backend, _rx) = create_test_backend();
5885
5886 backend
5887 .execute_js(
5888 r#"
5889 const editor = getEditor();
5890 globalThis.myEventHandler = function() { };
5891 editor.on("bufferSave", "myEventHandler");
5892 "#,
5893 "test.js",
5894 )
5895 .unwrap();
5896
5897 assert!(backend.has_handlers("bufferSave"));
5898 }
5899
5900 #[test]
5901 fn test_api_off_event_unregistration() {
5902 let (mut backend, _rx) = create_test_backend();
5903
5904 backend
5905 .execute_js(
5906 r#"
5907 const editor = getEditor();
5908 globalThis.myEventHandler = function() { };
5909 editor.on("bufferSave", "myEventHandler");
5910 editor.off("bufferSave", "myEventHandler");
5911 "#,
5912 "test.js",
5913 )
5914 .unwrap();
5915
5916 assert!(!backend.has_handlers("bufferSave"));
5918 }
5919
5920 #[tokio::test]
5921 async fn test_emit_event() {
5922 let (mut backend, rx) = create_test_backend();
5923
5924 backend
5925 .execute_js(
5926 r#"
5927 const editor = getEditor();
5928 globalThis.onSaveHandler = function(data) {
5929 editor.setStatus("saved: " + JSON.stringify(data));
5930 };
5931 editor.on("bufferSave", "onSaveHandler");
5932 "#,
5933 "test.js",
5934 )
5935 .unwrap();
5936
5937 while rx.try_recv().is_ok() {}
5939
5940 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5942 backend.emit("bufferSave", &event_data).await.unwrap();
5943
5944 let cmd = rx.try_recv().unwrap();
5945 match cmd {
5946 PluginCommand::SetStatus { message } => {
5947 assert!(message.contains("/test.txt"));
5948 }
5949 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5950 }
5951 }
5952
5953 #[test]
5954 fn test_api_copy_to_clipboard() {
5955 let (mut backend, rx) = create_test_backend();
5956
5957 backend
5958 .execute_js(
5959 r#"
5960 const editor = getEditor();
5961 editor.copyToClipboard("clipboard text");
5962 "#,
5963 "test.js",
5964 )
5965 .unwrap();
5966
5967 let cmd = rx.try_recv().unwrap();
5968 match cmd {
5969 PluginCommand::SetClipboard { text } => {
5970 assert_eq!(text, "clipboard text");
5971 }
5972 _ => panic!("Expected SetClipboard, got {:?}", cmd),
5973 }
5974 }
5975
5976 #[test]
5977 fn test_api_open_file() {
5978 let (mut backend, rx) = create_test_backend();
5979
5980 backend
5982 .execute_js(
5983 r#"
5984 const editor = getEditor();
5985 editor.openFile("/path/to/file.txt", null, null);
5986 "#,
5987 "test.js",
5988 )
5989 .unwrap();
5990
5991 let cmd = rx.try_recv().unwrap();
5992 match cmd {
5993 PluginCommand::OpenFileAtLocation { path, line, column } => {
5994 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
5995 assert!(line.is_none());
5996 assert!(column.is_none());
5997 }
5998 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
5999 }
6000 }
6001
6002 #[test]
6003 fn test_api_delete_range() {
6004 let (mut backend, rx) = create_test_backend();
6005
6006 backend
6008 .execute_js(
6009 r#"
6010 const editor = getEditor();
6011 editor.deleteRange(0, 10, 20);
6012 "#,
6013 "test.js",
6014 )
6015 .unwrap();
6016
6017 let cmd = rx.try_recv().unwrap();
6018 match cmd {
6019 PluginCommand::DeleteRange { range, .. } => {
6020 assert_eq!(range.start, 10);
6021 assert_eq!(range.end, 20);
6022 }
6023 _ => panic!("Expected DeleteRange, got {:?}", cmd),
6024 }
6025 }
6026
6027 #[test]
6028 fn test_api_insert_text() {
6029 let (mut backend, rx) = create_test_backend();
6030
6031 backend
6033 .execute_js(
6034 r#"
6035 const editor = getEditor();
6036 editor.insertText(0, 5, "inserted");
6037 "#,
6038 "test.js",
6039 )
6040 .unwrap();
6041
6042 let cmd = rx.try_recv().unwrap();
6043 match cmd {
6044 PluginCommand::InsertText { position, text, .. } => {
6045 assert_eq!(position, 5);
6046 assert_eq!(text, "inserted");
6047 }
6048 _ => panic!("Expected InsertText, got {:?}", cmd),
6049 }
6050 }
6051
6052 #[test]
6053 fn test_api_set_buffer_cursor() {
6054 let (mut backend, rx) = create_test_backend();
6055
6056 backend
6058 .execute_js(
6059 r#"
6060 const editor = getEditor();
6061 editor.setBufferCursor(0, 100);
6062 "#,
6063 "test.js",
6064 )
6065 .unwrap();
6066
6067 let cmd = rx.try_recv().unwrap();
6068 match cmd {
6069 PluginCommand::SetBufferCursor { position, .. } => {
6070 assert_eq!(position, 100);
6071 }
6072 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
6073 }
6074 }
6075
6076 #[test]
6077 fn test_api_get_cursor_position_from_state() {
6078 let (tx, _rx) = mpsc::channel();
6079 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6080
6081 {
6083 let mut state = state_snapshot.write().unwrap();
6084 state.primary_cursor = Some(CursorInfo {
6085 position: 42,
6086 selection: None,
6087 });
6088 }
6089
6090 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6091 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6092
6093 backend
6095 .execute_js(
6096 r#"
6097 const editor = getEditor();
6098 const pos = editor.getCursorPosition();
6099 globalThis._testResult = pos;
6100 "#,
6101 "test.js",
6102 )
6103 .unwrap();
6104
6105 backend
6107 .plugin_contexts
6108 .borrow()
6109 .get("test")
6110 .unwrap()
6111 .clone()
6112 .with(|ctx| {
6113 let global = ctx.globals();
6114 let result: u32 = global.get("_testResult").unwrap();
6115 assert_eq!(result, 42);
6116 });
6117 }
6118
6119 #[test]
6120 fn test_api_path_functions() {
6121 let (mut backend, _rx) = create_test_backend();
6122
6123 #[cfg(windows)]
6126 let absolute_path = r#"C:\\foo\\bar"#;
6127 #[cfg(not(windows))]
6128 let absolute_path = "/foo/bar";
6129
6130 let js_code = format!(
6132 r#"
6133 const editor = getEditor();
6134 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
6135 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
6136 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
6137 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
6138 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
6139 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
6140 "#,
6141 absolute_path
6142 );
6143 backend.execute_js(&js_code, "test.js").unwrap();
6144
6145 backend
6146 .plugin_contexts
6147 .borrow()
6148 .get("test")
6149 .unwrap()
6150 .clone()
6151 .with(|ctx| {
6152 let global = ctx.globals();
6153 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
6154 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
6155 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
6156 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
6157 assert!(!global.get::<_, bool>("_isRelative").unwrap());
6158 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
6159 });
6160 }
6161
6162 #[test]
6163 fn test_file_uri_to_path_and_back() {
6164 let (mut backend, _rx) = create_test_backend();
6165
6166 #[cfg(not(windows))]
6168 let js_code = r#"
6169 const editor = getEditor();
6170 // Basic file URI to path
6171 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
6172 // Percent-encoded characters
6173 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
6174 // Invalid URI returns empty string
6175 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6176 // Path to file URI
6177 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
6178 // Round-trip
6179 globalThis._roundtrip = editor.fileUriToPath(
6180 editor.pathToFileUri("/home/user/file.txt")
6181 );
6182 "#;
6183
6184 #[cfg(windows)]
6185 let js_code = r#"
6186 const editor = getEditor();
6187 // Windows URI with encoded colon (the bug from issue #1071)
6188 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
6189 // Windows URI with normal colon
6190 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
6191 // Invalid URI returns empty string
6192 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6193 // Path to file URI
6194 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
6195 // Round-trip
6196 globalThis._roundtrip = editor.fileUriToPath(
6197 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
6198 );
6199 "#;
6200
6201 backend.execute_js(js_code, "test.js").unwrap();
6202
6203 backend
6204 .plugin_contexts
6205 .borrow()
6206 .get("test")
6207 .unwrap()
6208 .clone()
6209 .with(|ctx| {
6210 let global = ctx.globals();
6211
6212 #[cfg(not(windows))]
6213 {
6214 assert_eq!(
6215 global.get::<_, String>("_path1").unwrap(),
6216 "/home/user/file.txt"
6217 );
6218 assert_eq!(
6219 global.get::<_, String>("_path2").unwrap(),
6220 "/home/user/my file.txt"
6221 );
6222 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6223 assert_eq!(
6224 global.get::<_, String>("_uri1").unwrap(),
6225 "file:///home/user/file.txt"
6226 );
6227 assert_eq!(
6228 global.get::<_, String>("_roundtrip").unwrap(),
6229 "/home/user/file.txt"
6230 );
6231 }
6232
6233 #[cfg(windows)]
6234 {
6235 assert_eq!(
6237 global.get::<_, String>("_path1").unwrap(),
6238 "C:\\Users\\admin\\Repos\\file.cs"
6239 );
6240 assert_eq!(
6241 global.get::<_, String>("_path2").unwrap(),
6242 "C:\\Users\\admin\\Repos\\file.cs"
6243 );
6244 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6245 assert_eq!(
6246 global.get::<_, String>("_uri1").unwrap(),
6247 "file:///C:/Users/admin/Repos/file.cs"
6248 );
6249 assert_eq!(
6250 global.get::<_, String>("_roundtrip").unwrap(),
6251 "C:\\Users\\admin\\Repos\\file.cs"
6252 );
6253 }
6254 });
6255 }
6256
6257 #[test]
6258 fn test_typescript_transpilation() {
6259 use fresh_parser_js::transpile_typescript;
6260
6261 let (mut backend, rx) = create_test_backend();
6262
6263 let ts_code = r#"
6265 const editor = getEditor();
6266 function greet(name: string): string {
6267 return "Hello, " + name;
6268 }
6269 editor.setStatus(greet("TypeScript"));
6270 "#;
6271
6272 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6274
6275 backend.execute_js(&js_code, "test.js").unwrap();
6277
6278 let cmd = rx.try_recv().unwrap();
6279 match cmd {
6280 PluginCommand::SetStatus { message } => {
6281 assert_eq!(message, "Hello, TypeScript");
6282 }
6283 _ => panic!("Expected SetStatus, got {:?}", cmd),
6284 }
6285 }
6286
6287 #[test]
6288 fn test_api_get_buffer_text_sends_command() {
6289 let (mut backend, rx) = create_test_backend();
6290
6291 backend
6293 .execute_js(
6294 r#"
6295 const editor = getEditor();
6296 // Store the promise for later
6297 globalThis._textPromise = editor.getBufferText(0, 10, 20);
6298 "#,
6299 "test.js",
6300 )
6301 .unwrap();
6302
6303 let cmd = rx.try_recv().unwrap();
6305 match cmd {
6306 PluginCommand::GetBufferText {
6307 buffer_id,
6308 start,
6309 end,
6310 request_id,
6311 } => {
6312 assert_eq!(buffer_id.0, 0);
6313 assert_eq!(start, 10);
6314 assert_eq!(end, 20);
6315 assert!(request_id > 0); }
6317 _ => panic!("Expected GetBufferText, got {:?}", cmd),
6318 }
6319 }
6320
6321 #[test]
6322 fn test_api_get_buffer_text_resolves_callback() {
6323 let (mut backend, rx) = create_test_backend();
6324
6325 backend
6327 .execute_js(
6328 r#"
6329 const editor = getEditor();
6330 globalThis._resolvedText = null;
6331 editor.getBufferText(0, 0, 100).then(text => {
6332 globalThis._resolvedText = text;
6333 });
6334 "#,
6335 "test.js",
6336 )
6337 .unwrap();
6338
6339 let request_id = match rx.try_recv().unwrap() {
6341 PluginCommand::GetBufferText { request_id, .. } => request_id,
6342 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6343 };
6344
6345 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6347
6348 backend
6350 .plugin_contexts
6351 .borrow()
6352 .get("test")
6353 .unwrap()
6354 .clone()
6355 .with(|ctx| {
6356 run_pending_jobs_checked(&ctx, "test async getText");
6357 });
6358
6359 backend
6361 .plugin_contexts
6362 .borrow()
6363 .get("test")
6364 .unwrap()
6365 .clone()
6366 .with(|ctx| {
6367 let global = ctx.globals();
6368 let result: String = global.get("_resolvedText").unwrap();
6369 assert_eq!(result, "hello world");
6370 });
6371 }
6372
6373 #[test]
6374 fn test_plugin_translation() {
6375 let (mut backend, _rx) = create_test_backend();
6376
6377 backend
6379 .execute_js(
6380 r#"
6381 const editor = getEditor();
6382 globalThis._translated = editor.t("test.key");
6383 "#,
6384 "test.js",
6385 )
6386 .unwrap();
6387
6388 backend
6389 .plugin_contexts
6390 .borrow()
6391 .get("test")
6392 .unwrap()
6393 .clone()
6394 .with(|ctx| {
6395 let global = ctx.globals();
6396 let result: String = global.get("_translated").unwrap();
6398 assert_eq!(result, "test.key");
6399 });
6400 }
6401
6402 #[test]
6403 fn test_plugin_translation_with_registered_strings() {
6404 let (mut backend, _rx) = create_test_backend();
6405
6406 let mut en_strings = std::collections::HashMap::new();
6408 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6409 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6410
6411 let mut strings = std::collections::HashMap::new();
6412 strings.insert("en".to_string(), en_strings);
6413
6414 if let Some(bridge) = backend
6416 .services
6417 .as_any()
6418 .downcast_ref::<TestServiceBridge>()
6419 {
6420 let mut en = bridge.en_strings.lock().unwrap();
6421 en.insert("greeting".to_string(), "Hello, World!".to_string());
6422 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6423 }
6424
6425 backend
6427 .execute_js(
6428 r#"
6429 const editor = getEditor();
6430 globalThis._greeting = editor.t("greeting");
6431 globalThis._prompt = editor.t("prompt.find_file");
6432 globalThis._missing = editor.t("nonexistent.key");
6433 "#,
6434 "test.js",
6435 )
6436 .unwrap();
6437
6438 backend
6439 .plugin_contexts
6440 .borrow()
6441 .get("test")
6442 .unwrap()
6443 .clone()
6444 .with(|ctx| {
6445 let global = ctx.globals();
6446 let greeting: String = global.get("_greeting").unwrap();
6447 assert_eq!(greeting, "Hello, World!");
6448
6449 let prompt: String = global.get("_prompt").unwrap();
6450 assert_eq!(prompt, "Find file: ");
6451
6452 let missing: String = global.get("_missing").unwrap();
6454 assert_eq!(missing, "nonexistent.key");
6455 });
6456 }
6457
6458 #[test]
6461 fn test_api_set_line_indicator() {
6462 let (mut backend, rx) = create_test_backend();
6463
6464 backend
6465 .execute_js(
6466 r#"
6467 const editor = getEditor();
6468 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6469 "#,
6470 "test.js",
6471 )
6472 .unwrap();
6473
6474 let cmd = rx.try_recv().unwrap();
6475 match cmd {
6476 PluginCommand::SetLineIndicator {
6477 buffer_id,
6478 line,
6479 namespace,
6480 symbol,
6481 color,
6482 priority,
6483 } => {
6484 assert_eq!(buffer_id.0, 1);
6485 assert_eq!(line, 5);
6486 assert_eq!(namespace, "test-ns");
6487 assert_eq!(symbol, "●");
6488 assert_eq!(color, (255, 0, 0));
6489 assert_eq!(priority, 10);
6490 }
6491 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
6492 }
6493 }
6494
6495 #[test]
6496 fn test_api_clear_line_indicators() {
6497 let (mut backend, rx) = create_test_backend();
6498
6499 backend
6500 .execute_js(
6501 r#"
6502 const editor = getEditor();
6503 editor.clearLineIndicators(1, "test-ns");
6504 "#,
6505 "test.js",
6506 )
6507 .unwrap();
6508
6509 let cmd = rx.try_recv().unwrap();
6510 match cmd {
6511 PluginCommand::ClearLineIndicators {
6512 buffer_id,
6513 namespace,
6514 } => {
6515 assert_eq!(buffer_id.0, 1);
6516 assert_eq!(namespace, "test-ns");
6517 }
6518 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
6519 }
6520 }
6521
6522 #[test]
6525 fn test_api_create_virtual_buffer_sends_command() {
6526 let (mut backend, rx) = create_test_backend();
6527
6528 backend
6529 .execute_js(
6530 r#"
6531 const editor = getEditor();
6532 editor.createVirtualBuffer({
6533 name: "*Test Buffer*",
6534 mode: "test-mode",
6535 readOnly: true,
6536 entries: [
6537 { text: "Line 1\n", properties: { type: "header" } },
6538 { text: "Line 2\n", properties: { type: "content" } }
6539 ],
6540 showLineNumbers: false,
6541 showCursors: true,
6542 editingDisabled: true
6543 });
6544 "#,
6545 "test.js",
6546 )
6547 .unwrap();
6548
6549 let cmd = rx.try_recv().unwrap();
6550 match cmd {
6551 PluginCommand::CreateVirtualBufferWithContent {
6552 name,
6553 mode,
6554 read_only,
6555 entries,
6556 show_line_numbers,
6557 show_cursors,
6558 editing_disabled,
6559 ..
6560 } => {
6561 assert_eq!(name, "*Test Buffer*");
6562 assert_eq!(mode, "test-mode");
6563 assert!(read_only);
6564 assert_eq!(entries.len(), 2);
6565 assert_eq!(entries[0].text, "Line 1\n");
6566 assert!(!show_line_numbers);
6567 assert!(show_cursors);
6568 assert!(editing_disabled);
6569 }
6570 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
6571 }
6572 }
6573
6574 #[test]
6575 fn test_api_set_virtual_buffer_content() {
6576 let (mut backend, rx) = create_test_backend();
6577
6578 backend
6579 .execute_js(
6580 r#"
6581 const editor = getEditor();
6582 editor.setVirtualBufferContent(5, [
6583 { text: "New content\n", properties: { type: "updated" } }
6584 ]);
6585 "#,
6586 "test.js",
6587 )
6588 .unwrap();
6589
6590 let cmd = rx.try_recv().unwrap();
6591 match cmd {
6592 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
6593 assert_eq!(buffer_id.0, 5);
6594 assert_eq!(entries.len(), 1);
6595 assert_eq!(entries[0].text, "New content\n");
6596 }
6597 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
6598 }
6599 }
6600
6601 #[test]
6604 fn test_api_add_overlay() {
6605 let (mut backend, rx) = create_test_backend();
6606
6607 backend
6608 .execute_js(
6609 r#"
6610 const editor = getEditor();
6611 editor.addOverlay(1, "highlight", 10, 20, {
6612 fg: [255, 128, 0],
6613 bg: [50, 50, 50],
6614 bold: true,
6615 });
6616 "#,
6617 "test.js",
6618 )
6619 .unwrap();
6620
6621 let cmd = rx.try_recv().unwrap();
6622 match cmd {
6623 PluginCommand::AddOverlay {
6624 buffer_id,
6625 namespace,
6626 range,
6627 options,
6628 } => {
6629 use fresh_core::api::OverlayColorSpec;
6630 assert_eq!(buffer_id.0, 1);
6631 assert!(namespace.is_some());
6632 assert_eq!(namespace.unwrap().as_str(), "highlight");
6633 assert_eq!(range, 10..20);
6634 assert!(matches!(
6635 options.fg,
6636 Some(OverlayColorSpec::Rgb(255, 128, 0))
6637 ));
6638 assert!(matches!(
6639 options.bg,
6640 Some(OverlayColorSpec::Rgb(50, 50, 50))
6641 ));
6642 assert!(!options.underline);
6643 assert!(options.bold);
6644 assert!(!options.italic);
6645 assert!(!options.extend_to_line_end);
6646 }
6647 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6648 }
6649 }
6650
6651 #[test]
6652 fn test_api_add_overlay_with_theme_keys() {
6653 let (mut backend, rx) = create_test_backend();
6654
6655 backend
6656 .execute_js(
6657 r#"
6658 const editor = getEditor();
6659 // Test with theme keys for colors
6660 editor.addOverlay(1, "themed", 0, 10, {
6661 fg: "ui.status_bar_fg",
6662 bg: "editor.selection_bg",
6663 });
6664 "#,
6665 "test.js",
6666 )
6667 .unwrap();
6668
6669 let cmd = rx.try_recv().unwrap();
6670 match cmd {
6671 PluginCommand::AddOverlay {
6672 buffer_id,
6673 namespace,
6674 range,
6675 options,
6676 } => {
6677 use fresh_core::api::OverlayColorSpec;
6678 assert_eq!(buffer_id.0, 1);
6679 assert!(namespace.is_some());
6680 assert_eq!(namespace.unwrap().as_str(), "themed");
6681 assert_eq!(range, 0..10);
6682 assert!(matches!(
6683 &options.fg,
6684 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
6685 ));
6686 assert!(matches!(
6687 &options.bg,
6688 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
6689 ));
6690 assert!(!options.underline);
6691 assert!(!options.bold);
6692 assert!(!options.italic);
6693 assert!(!options.extend_to_line_end);
6694 }
6695 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6696 }
6697 }
6698
6699 #[test]
6700 fn test_api_clear_namespace() {
6701 let (mut backend, rx) = create_test_backend();
6702
6703 backend
6704 .execute_js(
6705 r#"
6706 const editor = getEditor();
6707 editor.clearNamespace(1, "highlight");
6708 "#,
6709 "test.js",
6710 )
6711 .unwrap();
6712
6713 let cmd = rx.try_recv().unwrap();
6714 match cmd {
6715 PluginCommand::ClearNamespace {
6716 buffer_id,
6717 namespace,
6718 } => {
6719 assert_eq!(buffer_id.0, 1);
6720 assert_eq!(namespace.as_str(), "highlight");
6721 }
6722 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
6723 }
6724 }
6725
6726 #[test]
6729 fn test_api_get_theme_schema() {
6730 let (mut backend, _rx) = create_test_backend();
6731
6732 backend
6733 .execute_js(
6734 r#"
6735 const editor = getEditor();
6736 const schema = editor.getThemeSchema();
6737 globalThis._isObject = typeof schema === 'object' && schema !== null;
6738 "#,
6739 "test.js",
6740 )
6741 .unwrap();
6742
6743 backend
6744 .plugin_contexts
6745 .borrow()
6746 .get("test")
6747 .unwrap()
6748 .clone()
6749 .with(|ctx| {
6750 let global = ctx.globals();
6751 let is_object: bool = global.get("_isObject").unwrap();
6752 assert!(is_object);
6754 });
6755 }
6756
6757 #[test]
6758 fn test_api_get_builtin_themes() {
6759 let (mut backend, _rx) = create_test_backend();
6760
6761 backend
6762 .execute_js(
6763 r#"
6764 const editor = getEditor();
6765 const themes = editor.getBuiltinThemes();
6766 globalThis._isObject = typeof themes === 'object' && themes !== null;
6767 "#,
6768 "test.js",
6769 )
6770 .unwrap();
6771
6772 backend
6773 .plugin_contexts
6774 .borrow()
6775 .get("test")
6776 .unwrap()
6777 .clone()
6778 .with(|ctx| {
6779 let global = ctx.globals();
6780 let is_object: bool = global.get("_isObject").unwrap();
6781 assert!(is_object);
6783 });
6784 }
6785
6786 #[test]
6787 fn test_api_apply_theme() {
6788 let (mut backend, rx) = create_test_backend();
6789
6790 backend
6791 .execute_js(
6792 r#"
6793 const editor = getEditor();
6794 editor.applyTheme("dark");
6795 "#,
6796 "test.js",
6797 )
6798 .unwrap();
6799
6800 let cmd = rx.try_recv().unwrap();
6801 match cmd {
6802 PluginCommand::ApplyTheme { theme_name } => {
6803 assert_eq!(theme_name, "dark");
6804 }
6805 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
6806 }
6807 }
6808
6809 #[test]
6810 fn test_api_get_theme_data_missing() {
6811 let (mut backend, _rx) = create_test_backend();
6812
6813 backend
6814 .execute_js(
6815 r#"
6816 const editor = getEditor();
6817 const data = editor.getThemeData("nonexistent");
6818 globalThis._isNull = data === null;
6819 "#,
6820 "test.js",
6821 )
6822 .unwrap();
6823
6824 backend
6825 .plugin_contexts
6826 .borrow()
6827 .get("test")
6828 .unwrap()
6829 .clone()
6830 .with(|ctx| {
6831 let global = ctx.globals();
6832 let is_null: bool = global.get("_isNull").unwrap();
6833 assert!(is_null);
6835 });
6836 }
6837
6838 #[test]
6839 fn test_api_get_theme_data_present() {
6840 let (tx, _rx) = mpsc::channel();
6842 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6843 let services = Arc::new(ThemeCacheTestBridge {
6844 inner: TestServiceBridge::new(),
6845 });
6846 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6847
6848 backend
6849 .execute_js(
6850 r#"
6851 const editor = getEditor();
6852 const data = editor.getThemeData("test-theme");
6853 globalThis._hasData = data !== null && typeof data === 'object';
6854 globalThis._name = data ? data.name : null;
6855 "#,
6856 "test.js",
6857 )
6858 .unwrap();
6859
6860 backend
6861 .plugin_contexts
6862 .borrow()
6863 .get("test")
6864 .unwrap()
6865 .clone()
6866 .with(|ctx| {
6867 let global = ctx.globals();
6868 let has_data: bool = global.get("_hasData").unwrap();
6869 assert!(has_data, "getThemeData should return theme object");
6870 let name: String = global.get("_name").unwrap();
6871 assert_eq!(name, "test-theme");
6872 });
6873 }
6874
6875 #[test]
6876 fn test_api_theme_file_exists() {
6877 let (mut backend, _rx) = create_test_backend();
6878
6879 backend
6880 .execute_js(
6881 r#"
6882 const editor = getEditor();
6883 globalThis._exists = editor.themeFileExists("anything");
6884 "#,
6885 "test.js",
6886 )
6887 .unwrap();
6888
6889 backend
6890 .plugin_contexts
6891 .borrow()
6892 .get("test")
6893 .unwrap()
6894 .clone()
6895 .with(|ctx| {
6896 let global = ctx.globals();
6897 let exists: bool = global.get("_exists").unwrap();
6898 assert!(!exists);
6900 });
6901 }
6902
6903 #[test]
6904 fn test_api_save_theme_file_error() {
6905 let (mut backend, _rx) = create_test_backend();
6906
6907 backend
6908 .execute_js(
6909 r#"
6910 const editor = getEditor();
6911 let threw = false;
6912 try {
6913 editor.saveThemeFile("test", "{}");
6914 } catch (e) {
6915 threw = true;
6916 }
6917 globalThis._threw = threw;
6918 "#,
6919 "test.js",
6920 )
6921 .unwrap();
6922
6923 backend
6924 .plugin_contexts
6925 .borrow()
6926 .get("test")
6927 .unwrap()
6928 .clone()
6929 .with(|ctx| {
6930 let global = ctx.globals();
6931 let threw: bool = global.get("_threw").unwrap();
6932 assert!(threw);
6934 });
6935 }
6936
6937 struct ThemeCacheTestBridge {
6939 inner: TestServiceBridge,
6940 }
6941
6942 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6943 fn as_any(&self) -> &dyn std::any::Any {
6944 self
6945 }
6946 fn translate(
6947 &self,
6948 plugin_name: &str,
6949 key: &str,
6950 args: &HashMap<String, String>,
6951 ) -> String {
6952 self.inner.translate(plugin_name, key, args)
6953 }
6954 fn current_locale(&self) -> String {
6955 self.inner.current_locale()
6956 }
6957 fn set_js_execution_state(&self, state: String) {
6958 self.inner.set_js_execution_state(state);
6959 }
6960 fn clear_js_execution_state(&self) {
6961 self.inner.clear_js_execution_state();
6962 }
6963 fn get_theme_schema(&self) -> serde_json::Value {
6964 self.inner.get_theme_schema()
6965 }
6966 fn get_builtin_themes(&self) -> serde_json::Value {
6967 self.inner.get_builtin_themes()
6968 }
6969 fn register_command(&self, command: fresh_core::command::Command) {
6970 self.inner.register_command(command);
6971 }
6972 fn unregister_command(&self, name: &str) {
6973 self.inner.unregister_command(name);
6974 }
6975 fn unregister_commands_by_prefix(&self, prefix: &str) {
6976 self.inner.unregister_commands_by_prefix(prefix);
6977 }
6978 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6979 self.inner.unregister_commands_by_plugin(plugin_name);
6980 }
6981 fn plugins_dir(&self) -> std::path::PathBuf {
6982 self.inner.plugins_dir()
6983 }
6984 fn config_dir(&self) -> std::path::PathBuf {
6985 self.inner.config_dir()
6986 }
6987 fn data_dir(&self) -> std::path::PathBuf {
6988 self.inner.data_dir()
6989 }
6990 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6991 if name == "test-theme" {
6992 Some(serde_json::json!({
6993 "name": "test-theme",
6994 "editor": {},
6995 "ui": {},
6996 "syntax": {}
6997 }))
6998 } else {
6999 None
7000 }
7001 }
7002 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7003 Err("test bridge does not support save".to_string())
7004 }
7005 fn theme_file_exists(&self, name: &str) -> bool {
7006 name == "test-theme"
7007 }
7008 }
7009
7010 #[test]
7013 fn test_api_close_buffer() {
7014 let (mut backend, rx) = create_test_backend();
7015
7016 backend
7017 .execute_js(
7018 r#"
7019 const editor = getEditor();
7020 editor.closeBuffer(3);
7021 "#,
7022 "test.js",
7023 )
7024 .unwrap();
7025
7026 let cmd = rx.try_recv().unwrap();
7027 match cmd {
7028 PluginCommand::CloseBuffer { buffer_id } => {
7029 assert_eq!(buffer_id.0, 3);
7030 }
7031 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
7032 }
7033 }
7034
7035 #[test]
7036 fn test_api_focus_split() {
7037 let (mut backend, rx) = create_test_backend();
7038
7039 backend
7040 .execute_js(
7041 r#"
7042 const editor = getEditor();
7043 editor.focusSplit(2);
7044 "#,
7045 "test.js",
7046 )
7047 .unwrap();
7048
7049 let cmd = rx.try_recv().unwrap();
7050 match cmd {
7051 PluginCommand::FocusSplit { split_id } => {
7052 assert_eq!(split_id.0, 2);
7053 }
7054 _ => panic!("Expected FocusSplit, got {:?}", cmd),
7055 }
7056 }
7057
7058 #[test]
7059 fn test_api_list_buffers() {
7060 let (tx, _rx) = mpsc::channel();
7061 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7062
7063 {
7065 let mut state = state_snapshot.write().unwrap();
7066 state.buffers.insert(
7067 BufferId(0),
7068 BufferInfo {
7069 id: BufferId(0),
7070 path: Some(PathBuf::from("/test1.txt")),
7071 modified: false,
7072 length: 100,
7073 is_virtual: false,
7074 view_mode: "source".to_string(),
7075 is_composing_in_any_split: false,
7076 compose_width: None,
7077 language: "text".to_string(),
7078 is_preview: false,
7079 },
7080 );
7081 state.buffers.insert(
7082 BufferId(1),
7083 BufferInfo {
7084 id: BufferId(1),
7085 path: Some(PathBuf::from("/test2.txt")),
7086 modified: true,
7087 length: 200,
7088 is_virtual: false,
7089 view_mode: "source".to_string(),
7090 is_composing_in_any_split: false,
7091 compose_width: None,
7092 language: "text".to_string(),
7093 is_preview: false,
7094 },
7095 );
7096 }
7097
7098 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7099 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7100
7101 backend
7102 .execute_js(
7103 r#"
7104 const editor = getEditor();
7105 const buffers = editor.listBuffers();
7106 globalThis._isArray = Array.isArray(buffers);
7107 globalThis._length = buffers.length;
7108 "#,
7109 "test.js",
7110 )
7111 .unwrap();
7112
7113 backend
7114 .plugin_contexts
7115 .borrow()
7116 .get("test")
7117 .unwrap()
7118 .clone()
7119 .with(|ctx| {
7120 let global = ctx.globals();
7121 let is_array: bool = global.get("_isArray").unwrap();
7122 let length: u32 = global.get("_length").unwrap();
7123 assert!(is_array);
7124 assert_eq!(length, 2);
7125 });
7126 }
7127
7128 #[test]
7131 fn test_api_start_prompt() {
7132 let (mut backend, rx) = create_test_backend();
7133
7134 backend
7135 .execute_js(
7136 r#"
7137 const editor = getEditor();
7138 editor.startPrompt("Enter value:", "test-prompt");
7139 "#,
7140 "test.js",
7141 )
7142 .unwrap();
7143
7144 let cmd = rx.try_recv().unwrap();
7145 match cmd {
7146 PluginCommand::StartPrompt { label, prompt_type } => {
7147 assert_eq!(label, "Enter value:");
7148 assert_eq!(prompt_type, "test-prompt");
7149 }
7150 _ => panic!("Expected StartPrompt, got {:?}", cmd),
7151 }
7152 }
7153
7154 #[test]
7155 fn test_api_start_prompt_with_initial() {
7156 let (mut backend, rx) = create_test_backend();
7157
7158 backend
7159 .execute_js(
7160 r#"
7161 const editor = getEditor();
7162 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
7163 "#,
7164 "test.js",
7165 )
7166 .unwrap();
7167
7168 let cmd = rx.try_recv().unwrap();
7169 match cmd {
7170 PluginCommand::StartPromptWithInitial {
7171 label,
7172 prompt_type,
7173 initial_value,
7174 } => {
7175 assert_eq!(label, "Enter value:");
7176 assert_eq!(prompt_type, "test-prompt");
7177 assert_eq!(initial_value, "default");
7178 }
7179 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
7180 }
7181 }
7182
7183 #[test]
7184 fn test_api_set_prompt_suggestions() {
7185 let (mut backend, rx) = create_test_backend();
7186
7187 backend
7188 .execute_js(
7189 r#"
7190 const editor = getEditor();
7191 editor.setPromptSuggestions([
7192 { text: "Option 1", value: "opt1" },
7193 { text: "Option 2", value: "opt2" }
7194 ]);
7195 "#,
7196 "test.js",
7197 )
7198 .unwrap();
7199
7200 let cmd = rx.try_recv().unwrap();
7201 match cmd {
7202 PluginCommand::SetPromptSuggestions { suggestions } => {
7203 assert_eq!(suggestions.len(), 2);
7204 assert_eq!(suggestions[0].text, "Option 1");
7205 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
7206 }
7207 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
7208 }
7209 }
7210
7211 #[test]
7214 fn test_api_get_active_buffer_id() {
7215 let (tx, _rx) = mpsc::channel();
7216 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7217
7218 {
7219 let mut state = state_snapshot.write().unwrap();
7220 state.active_buffer_id = BufferId(42);
7221 }
7222
7223 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7224 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7225
7226 backend
7227 .execute_js(
7228 r#"
7229 const editor = getEditor();
7230 globalThis._activeId = editor.getActiveBufferId();
7231 "#,
7232 "test.js",
7233 )
7234 .unwrap();
7235
7236 backend
7237 .plugin_contexts
7238 .borrow()
7239 .get("test")
7240 .unwrap()
7241 .clone()
7242 .with(|ctx| {
7243 let global = ctx.globals();
7244 let result: u32 = global.get("_activeId").unwrap();
7245 assert_eq!(result, 42);
7246 });
7247 }
7248
7249 #[test]
7250 fn test_api_get_active_split_id() {
7251 let (tx, _rx) = mpsc::channel();
7252 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7253
7254 {
7255 let mut state = state_snapshot.write().unwrap();
7256 state.active_split_id = 7;
7257 }
7258
7259 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7260 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7261
7262 backend
7263 .execute_js(
7264 r#"
7265 const editor = getEditor();
7266 globalThis._splitId = editor.getActiveSplitId();
7267 "#,
7268 "test.js",
7269 )
7270 .unwrap();
7271
7272 backend
7273 .plugin_contexts
7274 .borrow()
7275 .get("test")
7276 .unwrap()
7277 .clone()
7278 .with(|ctx| {
7279 let global = ctx.globals();
7280 let result: u32 = global.get("_splitId").unwrap();
7281 assert_eq!(result, 7);
7282 });
7283 }
7284
7285 #[test]
7288 fn test_api_file_exists() {
7289 let (mut backend, _rx) = create_test_backend();
7290
7291 backend
7292 .execute_js(
7293 r#"
7294 const editor = getEditor();
7295 // Test with a path that definitely exists
7296 globalThis._exists = editor.fileExists("/");
7297 "#,
7298 "test.js",
7299 )
7300 .unwrap();
7301
7302 backend
7303 .plugin_contexts
7304 .borrow()
7305 .get("test")
7306 .unwrap()
7307 .clone()
7308 .with(|ctx| {
7309 let global = ctx.globals();
7310 let result: bool = global.get("_exists").unwrap();
7311 assert!(result);
7312 });
7313 }
7314
7315 #[test]
7316 fn test_api_get_cwd() {
7317 let (mut backend, _rx) = create_test_backend();
7318
7319 backend
7320 .execute_js(
7321 r#"
7322 const editor = getEditor();
7323 globalThis._cwd = editor.getCwd();
7324 "#,
7325 "test.js",
7326 )
7327 .unwrap();
7328
7329 backend
7330 .plugin_contexts
7331 .borrow()
7332 .get("test")
7333 .unwrap()
7334 .clone()
7335 .with(|ctx| {
7336 let global = ctx.globals();
7337 let result: String = global.get("_cwd").unwrap();
7338 assert!(!result.is_empty());
7340 });
7341 }
7342
7343 #[test]
7344 fn test_api_get_env() {
7345 let (mut backend, _rx) = create_test_backend();
7346
7347 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
7349
7350 backend
7351 .execute_js(
7352 r#"
7353 const editor = getEditor();
7354 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
7355 "#,
7356 "test.js",
7357 )
7358 .unwrap();
7359
7360 backend
7361 .plugin_contexts
7362 .borrow()
7363 .get("test")
7364 .unwrap()
7365 .clone()
7366 .with(|ctx| {
7367 let global = ctx.globals();
7368 let result: Option<String> = global.get("_envVal").unwrap();
7369 assert_eq!(result, Some("test_value".to_string()));
7370 });
7371
7372 std::env::remove_var("TEST_PLUGIN_VAR");
7373 }
7374
7375 #[test]
7376 fn test_api_get_config() {
7377 let (mut backend, _rx) = create_test_backend();
7378
7379 backend
7380 .execute_js(
7381 r#"
7382 const editor = getEditor();
7383 const config = editor.getConfig();
7384 globalThis._isObject = typeof config === 'object';
7385 "#,
7386 "test.js",
7387 )
7388 .unwrap();
7389
7390 backend
7391 .plugin_contexts
7392 .borrow()
7393 .get("test")
7394 .unwrap()
7395 .clone()
7396 .with(|ctx| {
7397 let global = ctx.globals();
7398 let is_object: bool = global.get("_isObject").unwrap();
7399 assert!(is_object);
7401 });
7402 }
7403
7404 #[test]
7405 fn test_api_get_themes_dir() {
7406 let (mut backend, _rx) = create_test_backend();
7407
7408 backend
7409 .execute_js(
7410 r#"
7411 const editor = getEditor();
7412 globalThis._themesDir = editor.getThemesDir();
7413 "#,
7414 "test.js",
7415 )
7416 .unwrap();
7417
7418 backend
7419 .plugin_contexts
7420 .borrow()
7421 .get("test")
7422 .unwrap()
7423 .clone()
7424 .with(|ctx| {
7425 let global = ctx.globals();
7426 let result: String = global.get("_themesDir").unwrap();
7427 assert!(!result.is_empty());
7429 });
7430 }
7431
7432 #[test]
7435 fn test_api_read_dir() {
7436 let (mut backend, _rx) = create_test_backend();
7437
7438 backend
7439 .execute_js(
7440 r#"
7441 const editor = getEditor();
7442 const entries = editor.readDir("/tmp");
7443 globalThis._isArray = Array.isArray(entries);
7444 globalThis._length = entries.length;
7445 "#,
7446 "test.js",
7447 )
7448 .unwrap();
7449
7450 backend
7451 .plugin_contexts
7452 .borrow()
7453 .get("test")
7454 .unwrap()
7455 .clone()
7456 .with(|ctx| {
7457 let global = ctx.globals();
7458 let is_array: bool = global.get("_isArray").unwrap();
7459 let length: u32 = global.get("_length").unwrap();
7460 assert!(is_array);
7462 let _ = length;
7464 });
7465 }
7466
7467 #[test]
7470 fn test_api_execute_action() {
7471 let (mut backend, rx) = create_test_backend();
7472
7473 backend
7474 .execute_js(
7475 r#"
7476 const editor = getEditor();
7477 editor.executeAction("move_cursor_up");
7478 "#,
7479 "test.js",
7480 )
7481 .unwrap();
7482
7483 let cmd = rx.try_recv().unwrap();
7484 match cmd {
7485 PluginCommand::ExecuteAction { action_name } => {
7486 assert_eq!(action_name, "move_cursor_up");
7487 }
7488 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
7489 }
7490 }
7491
7492 #[test]
7495 fn test_api_debug() {
7496 let (mut backend, _rx) = create_test_backend();
7497
7498 backend
7500 .execute_js(
7501 r#"
7502 const editor = getEditor();
7503 editor.debug("Test debug message");
7504 editor.debug("Another message with special chars: <>&\"'");
7505 "#,
7506 "test.js",
7507 )
7508 .unwrap();
7509 }
7511
7512 #[test]
7515 fn test_typescript_preamble_generated() {
7516 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
7518 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
7519 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
7520 println!(
7521 "Generated {} bytes of TypeScript preamble",
7522 JSEDITORAPI_TS_PREAMBLE.len()
7523 );
7524 }
7525
7526 #[test]
7527 fn test_typescript_editor_api_generated() {
7528 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
7530 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
7531 println!(
7532 "Generated {} bytes of EditorAPI interface",
7533 JSEDITORAPI_TS_EDITOR_API.len()
7534 );
7535 }
7536
7537 #[test]
7538 fn test_js_methods_list() {
7539 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
7541 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
7542 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
7544 if i < 20 {
7545 println!(" - {}", method);
7546 }
7547 }
7548 if JSEDITORAPI_JS_METHODS.len() > 20 {
7549 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
7550 }
7551 }
7552
7553 #[test]
7556 fn test_api_load_plugin_sends_command() {
7557 let (mut backend, rx) = create_test_backend();
7558
7559 backend
7561 .execute_js(
7562 r#"
7563 const editor = getEditor();
7564 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
7565 "#,
7566 "test.js",
7567 )
7568 .unwrap();
7569
7570 let cmd = rx.try_recv().unwrap();
7572 match cmd {
7573 PluginCommand::LoadPlugin { path, callback_id } => {
7574 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
7575 assert!(callback_id.0 > 0); }
7577 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
7578 }
7579 }
7580
7581 #[test]
7582 fn test_api_unload_plugin_sends_command() {
7583 let (mut backend, rx) = create_test_backend();
7584
7585 backend
7587 .execute_js(
7588 r#"
7589 const editor = getEditor();
7590 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
7591 "#,
7592 "test.js",
7593 )
7594 .unwrap();
7595
7596 let cmd = rx.try_recv().unwrap();
7598 match cmd {
7599 PluginCommand::UnloadPlugin { name, callback_id } => {
7600 assert_eq!(name, "my-plugin");
7601 assert!(callback_id.0 > 0); }
7603 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
7604 }
7605 }
7606
7607 #[test]
7608 fn test_api_reload_plugin_sends_command() {
7609 let (mut backend, rx) = create_test_backend();
7610
7611 backend
7613 .execute_js(
7614 r#"
7615 const editor = getEditor();
7616 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
7617 "#,
7618 "test.js",
7619 )
7620 .unwrap();
7621
7622 let cmd = rx.try_recv().unwrap();
7624 match cmd {
7625 PluginCommand::ReloadPlugin { name, callback_id } => {
7626 assert_eq!(name, "my-plugin");
7627 assert!(callback_id.0 > 0); }
7629 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
7630 }
7631 }
7632
7633 #[test]
7634 fn test_api_load_plugin_resolves_callback() {
7635 let (mut backend, rx) = create_test_backend();
7636
7637 backend
7639 .execute_js(
7640 r#"
7641 const editor = getEditor();
7642 globalThis._loadResult = null;
7643 editor.loadPlugin("/path/to/plugin.ts").then(result => {
7644 globalThis._loadResult = result;
7645 });
7646 "#,
7647 "test.js",
7648 )
7649 .unwrap();
7650
7651 let callback_id = match rx.try_recv().unwrap() {
7653 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
7654 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
7655 };
7656
7657 backend.resolve_callback(callback_id, "true");
7659
7660 backend
7662 .plugin_contexts
7663 .borrow()
7664 .get("test")
7665 .unwrap()
7666 .clone()
7667 .with(|ctx| {
7668 run_pending_jobs_checked(&ctx, "test async loadPlugin");
7669 });
7670
7671 backend
7673 .plugin_contexts
7674 .borrow()
7675 .get("test")
7676 .unwrap()
7677 .clone()
7678 .with(|ctx| {
7679 let global = ctx.globals();
7680 let result: bool = global.get("_loadResult").unwrap();
7681 assert!(result);
7682 });
7683 }
7684
7685 #[test]
7686 fn test_api_version() {
7687 let (mut backend, _rx) = create_test_backend();
7688
7689 backend
7690 .execute_js(
7691 r#"
7692 const editor = getEditor();
7693 globalThis._apiVersion = editor.apiVersion();
7694 "#,
7695 "test.js",
7696 )
7697 .unwrap();
7698
7699 backend
7700 .plugin_contexts
7701 .borrow()
7702 .get("test")
7703 .unwrap()
7704 .clone()
7705 .with(|ctx| {
7706 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
7707 assert_eq!(version, 2);
7708 });
7709 }
7710
7711 #[test]
7712 fn test_api_unload_plugin_rejects_on_error() {
7713 let (mut backend, rx) = create_test_backend();
7714
7715 backend
7717 .execute_js(
7718 r#"
7719 const editor = getEditor();
7720 globalThis._unloadError = null;
7721 editor.unloadPlugin("nonexistent-plugin").catch(err => {
7722 globalThis._unloadError = err.message || String(err);
7723 });
7724 "#,
7725 "test.js",
7726 )
7727 .unwrap();
7728
7729 let callback_id = match rx.try_recv().unwrap() {
7731 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
7732 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
7733 };
7734
7735 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
7737
7738 backend
7740 .plugin_contexts
7741 .borrow()
7742 .get("test")
7743 .unwrap()
7744 .clone()
7745 .with(|ctx| {
7746 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
7747 });
7748
7749 backend
7751 .plugin_contexts
7752 .borrow()
7753 .get("test")
7754 .unwrap()
7755 .clone()
7756 .with(|ctx| {
7757 let global = ctx.globals();
7758 let error: String = global.get("_unloadError").unwrap();
7759 assert!(error.contains("nonexistent-plugin"));
7760 });
7761 }
7762
7763 #[test]
7764 fn test_api_set_global_state() {
7765 let (mut backend, rx) = create_test_backend();
7766
7767 backend
7768 .execute_js(
7769 r#"
7770 const editor = getEditor();
7771 editor.setGlobalState("myKey", { enabled: true, count: 42 });
7772 "#,
7773 "test_plugin.js",
7774 )
7775 .unwrap();
7776
7777 let cmd = rx.try_recv().unwrap();
7778 match cmd {
7779 PluginCommand::SetGlobalState {
7780 plugin_name,
7781 key,
7782 value,
7783 } => {
7784 assert_eq!(plugin_name, "test_plugin");
7785 assert_eq!(key, "myKey");
7786 let v = value.unwrap();
7787 assert_eq!(v["enabled"], serde_json::json!(true));
7788 assert_eq!(v["count"], serde_json::json!(42));
7789 }
7790 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7791 }
7792 }
7793
7794 #[test]
7795 fn test_api_set_global_state_delete() {
7796 let (mut backend, rx) = create_test_backend();
7797
7798 backend
7799 .execute_js(
7800 r#"
7801 const editor = getEditor();
7802 editor.setGlobalState("myKey", null);
7803 "#,
7804 "test_plugin.js",
7805 )
7806 .unwrap();
7807
7808 let cmd = rx.try_recv().unwrap();
7809 match cmd {
7810 PluginCommand::SetGlobalState {
7811 plugin_name,
7812 key,
7813 value,
7814 } => {
7815 assert_eq!(plugin_name, "test_plugin");
7816 assert_eq!(key, "myKey");
7817 assert!(value.is_none(), "null should delete the key");
7818 }
7819 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7820 }
7821 }
7822
7823 #[test]
7824 fn test_api_get_global_state_roundtrip() {
7825 let (mut backend, _rx) = create_test_backend();
7826
7827 backend
7829 .execute_js(
7830 r#"
7831 const editor = getEditor();
7832 editor.setGlobalState("flag", true);
7833 globalThis._result = editor.getGlobalState("flag");
7834 "#,
7835 "test_plugin.js",
7836 )
7837 .unwrap();
7838
7839 backend
7840 .plugin_contexts
7841 .borrow()
7842 .get("test_plugin")
7843 .unwrap()
7844 .clone()
7845 .with(|ctx| {
7846 let global = ctx.globals();
7847 let result: bool = global.get("_result").unwrap();
7848 assert!(
7849 result,
7850 "getGlobalState should return the value set by setGlobalState"
7851 );
7852 });
7853 }
7854
7855 #[test]
7856 fn test_api_get_global_state_missing_key() {
7857 let (mut backend, _rx) = create_test_backend();
7858
7859 backend
7860 .execute_js(
7861 r#"
7862 const editor = getEditor();
7863 globalThis._result = editor.getGlobalState("nonexistent");
7864 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
7865 "#,
7866 "test_plugin.js",
7867 )
7868 .unwrap();
7869
7870 backend
7871 .plugin_contexts
7872 .borrow()
7873 .get("test_plugin")
7874 .unwrap()
7875 .clone()
7876 .with(|ctx| {
7877 let global = ctx.globals();
7878 let is_undefined: bool = global.get("_isUndefined").unwrap();
7879 assert!(
7880 is_undefined,
7881 "getGlobalState for missing key should return undefined"
7882 );
7883 });
7884 }
7885
7886 #[test]
7887 fn test_api_global_state_isolation_between_plugins() {
7888 let (tx, _rx) = mpsc::channel();
7890 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7891 let services = Arc::new(TestServiceBridge::new());
7892
7893 let mut backend_a =
7895 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7896 .unwrap();
7897 backend_a
7898 .execute_js(
7899 r#"
7900 const editor = getEditor();
7901 editor.setGlobalState("flag", "from_plugin_a");
7902 "#,
7903 "plugin_a.js",
7904 )
7905 .unwrap();
7906
7907 let mut backend_b =
7909 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7910 .unwrap();
7911 backend_b
7912 .execute_js(
7913 r#"
7914 const editor = getEditor();
7915 editor.setGlobalState("flag", "from_plugin_b");
7916 "#,
7917 "plugin_b.js",
7918 )
7919 .unwrap();
7920
7921 backend_a
7923 .execute_js(
7924 r#"
7925 const editor = getEditor();
7926 globalThis._aValue = editor.getGlobalState("flag");
7927 "#,
7928 "plugin_a.js",
7929 )
7930 .unwrap();
7931
7932 backend_a
7933 .plugin_contexts
7934 .borrow()
7935 .get("plugin_a")
7936 .unwrap()
7937 .clone()
7938 .with(|ctx| {
7939 let global = ctx.globals();
7940 let a_value: String = global.get("_aValue").unwrap();
7941 assert_eq!(
7942 a_value, "from_plugin_a",
7943 "Plugin A should see its own value, not plugin B's"
7944 );
7945 });
7946
7947 backend_b
7949 .execute_js(
7950 r#"
7951 const editor = getEditor();
7952 globalThis._bValue = editor.getGlobalState("flag");
7953 "#,
7954 "plugin_b.js",
7955 )
7956 .unwrap();
7957
7958 backend_b
7959 .plugin_contexts
7960 .borrow()
7961 .get("plugin_b")
7962 .unwrap()
7963 .clone()
7964 .with(|ctx| {
7965 let global = ctx.globals();
7966 let b_value: String = global.get("_bValue").unwrap();
7967 assert_eq!(
7968 b_value, "from_plugin_b",
7969 "Plugin B should see its own value, not plugin A's"
7970 );
7971 });
7972 }
7973
7974 #[test]
7975 fn test_register_command_collision_different_plugins() {
7976 let (mut backend, _rx) = create_test_backend();
7977
7978 backend
7980 .execute_js(
7981 r#"
7982 const editor = getEditor();
7983 globalThis.handlerA = function() { };
7984 editor.registerCommand("My Command", "From A", "handlerA", null);
7985 "#,
7986 "plugin_a.js",
7987 )
7988 .unwrap();
7989
7990 let result = backend.execute_js(
7992 r#"
7993 const editor = getEditor();
7994 globalThis.handlerB = function() { };
7995 editor.registerCommand("My Command", "From B", "handlerB", null);
7996 "#,
7997 "plugin_b.js",
7998 );
7999
8000 assert!(
8001 result.is_err(),
8002 "Second plugin registering the same command name should fail"
8003 );
8004 let err_msg = result.unwrap_err().to_string();
8005 assert!(
8006 err_msg.contains("already registered"),
8007 "Error should mention collision: {}",
8008 err_msg
8009 );
8010 }
8011
8012 #[test]
8013 fn test_register_command_same_plugin_allowed() {
8014 let (mut backend, _rx) = create_test_backend();
8015
8016 backend
8018 .execute_js(
8019 r#"
8020 const editor = getEditor();
8021 globalThis.handler1 = function() { };
8022 editor.registerCommand("My Command", "Version 1", "handler1", null);
8023 globalThis.handler2 = function() { };
8024 editor.registerCommand("My Command", "Version 2", "handler2", null);
8025 "#,
8026 "plugin_a.js",
8027 )
8028 .unwrap();
8029 }
8030
8031 #[test]
8032 fn test_register_command_after_unregister() {
8033 let (mut backend, _rx) = create_test_backend();
8034
8035 backend
8037 .execute_js(
8038 r#"
8039 const editor = getEditor();
8040 globalThis.handlerA = function() { };
8041 editor.registerCommand("My Command", "From A", "handlerA", null);
8042 editor.unregisterCommand("My Command");
8043 "#,
8044 "plugin_a.js",
8045 )
8046 .unwrap();
8047
8048 backend
8050 .execute_js(
8051 r#"
8052 const editor = getEditor();
8053 globalThis.handlerB = function() { };
8054 editor.registerCommand("My Command", "From B", "handlerB", null);
8055 "#,
8056 "plugin_b.js",
8057 )
8058 .unwrap();
8059 }
8060
8061 #[test]
8062 fn test_register_command_collision_caught_in_try_catch() {
8063 let (mut backend, _rx) = create_test_backend();
8064
8065 backend
8067 .execute_js(
8068 r#"
8069 const editor = getEditor();
8070 globalThis.handlerA = function() { };
8071 editor.registerCommand("My Command", "From A", "handlerA", null);
8072 "#,
8073 "plugin_a.js",
8074 )
8075 .unwrap();
8076
8077 backend
8079 .execute_js(
8080 r#"
8081 const editor = getEditor();
8082 globalThis.handlerB = function() { };
8083 let caught = false;
8084 try {
8085 editor.registerCommand("My Command", "From B", "handlerB", null);
8086 } catch (e) {
8087 caught = true;
8088 }
8089 if (!caught) throw new Error("Expected collision error");
8090 "#,
8091 "plugin_b.js",
8092 )
8093 .unwrap();
8094 }
8095
8096 #[test]
8097 fn test_register_command_i18n_key_no_collision_across_plugins() {
8098 let (mut backend, _rx) = create_test_backend();
8099
8100 backend
8102 .execute_js(
8103 r#"
8104 const editor = getEditor();
8105 globalThis.handlerA = function() { };
8106 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
8107 "#,
8108 "plugin_a.js",
8109 )
8110 .unwrap();
8111
8112 backend
8115 .execute_js(
8116 r#"
8117 const editor = getEditor();
8118 globalThis.handlerB = function() { };
8119 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
8120 "#,
8121 "plugin_b.js",
8122 )
8123 .unwrap();
8124 }
8125
8126 #[test]
8127 fn test_register_command_non_i18n_still_collides() {
8128 let (mut backend, _rx) = create_test_backend();
8129
8130 backend
8132 .execute_js(
8133 r#"
8134 const editor = getEditor();
8135 globalThis.handlerA = function() { };
8136 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
8137 "#,
8138 "plugin_a.js",
8139 )
8140 .unwrap();
8141
8142 let result = backend.execute_js(
8144 r#"
8145 const editor = getEditor();
8146 globalThis.handlerB = function() { };
8147 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
8148 "#,
8149 "plugin_b.js",
8150 );
8151
8152 assert!(
8153 result.is_err(),
8154 "Non-%-prefixed names should still collide across plugins"
8155 );
8156 }
8157}