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