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::debug!("Plugin: {}", 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(
3185 &self,
3186 #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
3187 ) -> bool {
3188 self.command_sender
3189 .send(PluginCommand::SetPromptTitle { title })
3190 .is_ok()
3191 }
3192
3193 pub fn define_mode(
3197 &self,
3198 name: String,
3199 bindings_arr: Vec<Vec<String>>,
3200 read_only: rquickjs::function::Opt<bool>,
3201 allow_text_input: rquickjs::function::Opt<bool>,
3202 inherit_normal_bindings: rquickjs::function::Opt<bool>,
3203 ) -> bool {
3204 let bindings: Vec<(String, String)> = bindings_arr
3205 .into_iter()
3206 .filter_map(|arr| {
3207 if arr.len() >= 2 {
3208 Some((arr[0].clone(), arr[1].clone()))
3209 } else {
3210 None
3211 }
3212 })
3213 .collect();
3214
3215 {
3218 let mut registered = self.registered_actions.borrow_mut();
3219 for (_, cmd_name) in &bindings {
3220 registered.insert(
3221 cmd_name.clone(),
3222 PluginHandler {
3223 plugin_name: self.plugin_name.clone(),
3224 handler_name: cmd_name.clone(),
3225 },
3226 );
3227 }
3228 }
3229
3230 let allow_text = allow_text_input.0.unwrap_or(false);
3233 if allow_text {
3234 let mut registered = self.registered_actions.borrow_mut();
3235 registered.insert(
3236 "mode_text_input".to_string(),
3237 PluginHandler {
3238 plugin_name: self.plugin_name.clone(),
3239 handler_name: "mode_text_input".to_string(),
3240 },
3241 );
3242 }
3243
3244 self.command_sender
3245 .send(PluginCommand::DefineMode {
3246 name,
3247 bindings,
3248 read_only: read_only.0.unwrap_or(false),
3249 allow_text_input: allow_text,
3250 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
3251 plugin_name: Some(self.plugin_name.clone()),
3252 })
3253 .is_ok()
3254 }
3255
3256 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
3258 self.command_sender
3259 .send(PluginCommand::SetEditorMode { mode })
3260 .is_ok()
3261 }
3262
3263 pub fn get_editor_mode(&self) -> Option<String> {
3265 self.state_snapshot
3266 .read()
3267 .ok()
3268 .and_then(|s| s.editor_mode.clone())
3269 }
3270
3271 pub fn close_split(&self, split_id: u32) -> bool {
3275 self.command_sender
3276 .send(PluginCommand::CloseSplit {
3277 split_id: SplitId(split_id as usize),
3278 })
3279 .is_ok()
3280 }
3281
3282 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
3284 self.command_sender
3285 .send(PluginCommand::SetSplitBuffer {
3286 split_id: SplitId(split_id as usize),
3287 buffer_id: BufferId(buffer_id as usize),
3288 })
3289 .is_ok()
3290 }
3291
3292 pub fn focus_split(&self, split_id: u32) -> bool {
3294 self.command_sender
3295 .send(PluginCommand::FocusSplit {
3296 split_id: SplitId(split_id as usize),
3297 })
3298 .is_ok()
3299 }
3300
3301 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
3303 self.command_sender
3304 .send(PluginCommand::SetSplitScroll {
3305 split_id: SplitId(split_id as usize),
3306 top_byte: top_byte as usize,
3307 })
3308 .is_ok()
3309 }
3310
3311 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
3313 self.command_sender
3314 .send(PluginCommand::SetSplitRatio {
3315 split_id: SplitId(split_id as usize),
3316 ratio,
3317 })
3318 .is_ok()
3319 }
3320
3321 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
3323 self.command_sender
3324 .send(PluginCommand::SetSplitLabel {
3325 split_id: SplitId(split_id as usize),
3326 label,
3327 })
3328 .is_ok()
3329 }
3330
3331 pub fn clear_split_label(&self, split_id: u32) -> bool {
3333 self.command_sender
3334 .send(PluginCommand::ClearSplitLabel {
3335 split_id: SplitId(split_id as usize),
3336 })
3337 .is_ok()
3338 }
3339
3340 #[plugin_api(
3342 async_promise,
3343 js_name = "getSplitByLabel",
3344 ts_return = "number | null"
3345 )]
3346 #[qjs(rename = "_getSplitByLabelStart")]
3347 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
3348 let id = self.alloc_request_id();
3349 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
3350 label,
3351 request_id: id,
3352 });
3353 id
3354 }
3355
3356 pub fn distribute_splits_evenly(&self) -> bool {
3358 self.command_sender
3360 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
3361 .is_ok()
3362 }
3363
3364 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
3366 self.command_sender
3367 .send(PluginCommand::SetBufferCursor {
3368 buffer_id: BufferId(buffer_id as usize),
3369 position: position as usize,
3370 })
3371 .is_ok()
3372 }
3373
3374 #[qjs(rename = "setBufferShowCursors")]
3381 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
3382 self.command_sender
3383 .send(PluginCommand::SetBufferShowCursors {
3384 buffer_id: BufferId(buffer_id as usize),
3385 show,
3386 })
3387 .is_ok()
3388 }
3389
3390 #[allow(clippy::too_many_arguments)]
3394 pub fn set_line_indicator(
3395 &self,
3396 buffer_id: u32,
3397 line: u32,
3398 namespace: String,
3399 symbol: String,
3400 r: u8,
3401 g: u8,
3402 b: u8,
3403 priority: i32,
3404 ) -> bool {
3405 self.plugin_tracked_state
3407 .borrow_mut()
3408 .entry(self.plugin_name.clone())
3409 .or_default()
3410 .line_indicator_namespaces
3411 .push((BufferId(buffer_id as usize), namespace.clone()));
3412
3413 self.command_sender
3414 .send(PluginCommand::SetLineIndicator {
3415 buffer_id: BufferId(buffer_id as usize),
3416 line: line as usize,
3417 namespace,
3418 symbol,
3419 color: (r, g, b),
3420 priority,
3421 })
3422 .is_ok()
3423 }
3424
3425 #[allow(clippy::too_many_arguments)]
3427 pub fn set_line_indicators(
3428 &self,
3429 buffer_id: u32,
3430 lines: Vec<u32>,
3431 namespace: String,
3432 symbol: String,
3433 r: u8,
3434 g: u8,
3435 b: u8,
3436 priority: i32,
3437 ) -> bool {
3438 self.plugin_tracked_state
3440 .borrow_mut()
3441 .entry(self.plugin_name.clone())
3442 .or_default()
3443 .line_indicator_namespaces
3444 .push((BufferId(buffer_id as usize), namespace.clone()));
3445
3446 self.command_sender
3447 .send(PluginCommand::SetLineIndicators {
3448 buffer_id: BufferId(buffer_id as usize),
3449 lines: lines.into_iter().map(|l| l as usize).collect(),
3450 namespace,
3451 symbol,
3452 color: (r, g, b),
3453 priority,
3454 })
3455 .is_ok()
3456 }
3457
3458 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
3460 self.command_sender
3461 .send(PluginCommand::ClearLineIndicators {
3462 buffer_id: BufferId(buffer_id as usize),
3463 namespace,
3464 })
3465 .is_ok()
3466 }
3467
3468 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
3470 self.command_sender
3471 .send(PluginCommand::SetLineNumbers {
3472 buffer_id: BufferId(buffer_id as usize),
3473 enabled,
3474 })
3475 .is_ok()
3476 }
3477
3478 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
3480 self.command_sender
3481 .send(PluginCommand::SetViewMode {
3482 buffer_id: BufferId(buffer_id as usize),
3483 mode,
3484 })
3485 .is_ok()
3486 }
3487
3488 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
3490 self.command_sender
3491 .send(PluginCommand::SetLineWrap {
3492 buffer_id: BufferId(buffer_id as usize),
3493 split_id: split_id.map(|s| SplitId(s as usize)),
3494 enabled,
3495 })
3496 .is_ok()
3497 }
3498
3499 pub fn set_view_state<'js>(
3503 &self,
3504 ctx: rquickjs::Ctx<'js>,
3505 buffer_id: u32,
3506 key: String,
3507 value: Value<'js>,
3508 ) -> bool {
3509 let bid = BufferId(buffer_id as usize);
3510
3511 let json_value = if value.is_undefined() || value.is_null() {
3513 None
3514 } else {
3515 Some(js_to_json(&ctx, value))
3516 };
3517
3518 if let Ok(mut snapshot) = self.state_snapshot.write() {
3520 if let Some(ref json_val) = json_value {
3521 snapshot
3522 .plugin_view_states
3523 .entry(bid)
3524 .or_default()
3525 .insert(key.clone(), json_val.clone());
3526 } else {
3527 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
3529 map.remove(&key);
3530 if map.is_empty() {
3531 snapshot.plugin_view_states.remove(&bid);
3532 }
3533 }
3534 }
3535 }
3536
3537 self.command_sender
3539 .send(PluginCommand::SetViewState {
3540 buffer_id: bid,
3541 key,
3542 value: json_value,
3543 })
3544 .is_ok()
3545 }
3546
3547 pub fn get_view_state<'js>(
3549 &self,
3550 ctx: rquickjs::Ctx<'js>,
3551 buffer_id: u32,
3552 key: String,
3553 ) -> rquickjs::Result<Value<'js>> {
3554 let bid = BufferId(buffer_id as usize);
3555 if let Ok(snapshot) = self.state_snapshot.read() {
3556 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
3557 if let Some(json_val) = map.get(&key) {
3558 return json_to_js_value(&ctx, json_val);
3559 }
3560 }
3561 }
3562 Ok(Value::new_undefined(ctx.clone()))
3563 }
3564
3565 pub fn set_global_state<'js>(
3571 &self,
3572 ctx: rquickjs::Ctx<'js>,
3573 key: String,
3574 value: Value<'js>,
3575 ) -> bool {
3576 let json_value = if value.is_undefined() || value.is_null() {
3578 None
3579 } else {
3580 Some(js_to_json(&ctx, value))
3581 };
3582
3583 if let Ok(mut snapshot) = self.state_snapshot.write() {
3585 if let Some(ref json_val) = json_value {
3586 snapshot
3587 .plugin_global_states
3588 .entry(self.plugin_name.clone())
3589 .or_default()
3590 .insert(key.clone(), json_val.clone());
3591 } else {
3592 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3594 map.remove(&key);
3595 if map.is_empty() {
3596 snapshot.plugin_global_states.remove(&self.plugin_name);
3597 }
3598 }
3599 }
3600 }
3601
3602 self.command_sender
3604 .send(PluginCommand::SetGlobalState {
3605 plugin_name: self.plugin_name.clone(),
3606 key,
3607 value: json_value,
3608 })
3609 .is_ok()
3610 }
3611
3612 pub fn get_global_state<'js>(
3616 &self,
3617 ctx: rquickjs::Ctx<'js>,
3618 key: String,
3619 ) -> rquickjs::Result<Value<'js>> {
3620 if let Ok(snapshot) = self.state_snapshot.read() {
3621 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3622 if let Some(json_val) = map.get(&key) {
3623 return json_to_js_value(&ctx, json_val);
3624 }
3625 }
3626 }
3627 Ok(Value::new_undefined(ctx.clone()))
3628 }
3629
3630 pub fn create_scroll_sync_group(
3634 &self,
3635 group_id: u32,
3636 left_split: u32,
3637 right_split: u32,
3638 ) -> bool {
3639 self.plugin_tracked_state
3641 .borrow_mut()
3642 .entry(self.plugin_name.clone())
3643 .or_default()
3644 .scroll_sync_group_ids
3645 .push(group_id);
3646 self.command_sender
3647 .send(PluginCommand::CreateScrollSyncGroup {
3648 group_id,
3649 left_split: SplitId(left_split as usize),
3650 right_split: SplitId(right_split as usize),
3651 })
3652 .is_ok()
3653 }
3654
3655 pub fn set_scroll_sync_anchors<'js>(
3657 &self,
3658 _ctx: rquickjs::Ctx<'js>,
3659 group_id: u32,
3660 anchors: Vec<Vec<u32>>,
3661 ) -> bool {
3662 let anchors: Vec<(usize, usize)> = anchors
3663 .into_iter()
3664 .filter_map(|pair| {
3665 if pair.len() >= 2 {
3666 Some((pair[0] as usize, pair[1] as usize))
3667 } else {
3668 None
3669 }
3670 })
3671 .collect();
3672 self.command_sender
3673 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3674 .is_ok()
3675 }
3676
3677 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3679 self.command_sender
3680 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3681 .is_ok()
3682 }
3683
3684 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3690 self.command_sender
3691 .send(PluginCommand::ExecuteActions { actions })
3692 .is_ok()
3693 }
3694
3695 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3699 self.command_sender
3700 .send(PluginCommand::ShowActionPopup {
3701 popup_id: opts.id,
3702 title: opts.title,
3703 message: opts.message,
3704 actions: opts.actions,
3705 })
3706 .is_ok()
3707 }
3708
3709 pub fn disable_lsp_for_language(&self, language: String) -> bool {
3711 self.command_sender
3712 .send(PluginCommand::DisableLspForLanguage { language })
3713 .is_ok()
3714 }
3715
3716 pub fn restart_lsp_for_language(&self, language: String) -> bool {
3718 self.command_sender
3719 .send(PluginCommand::RestartLspForLanguage { language })
3720 .is_ok()
3721 }
3722
3723 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3726 self.command_sender
3727 .send(PluginCommand::SetLspRootUri { language, uri })
3728 .is_ok()
3729 }
3730
3731 #[plugin_api(ts_return = "JsDiagnostic[]")]
3733 pub fn get_all_diagnostics<'js>(
3734 &self,
3735 ctx: rquickjs::Ctx<'js>,
3736 ) -> rquickjs::Result<Value<'js>> {
3737 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3738
3739 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3740 let mut result: Vec<JsDiagnostic> = Vec::new();
3742 for (uri, diags) in s.diagnostics.iter() {
3743 for diag in diags {
3744 result.push(JsDiagnostic {
3745 uri: uri.clone(),
3746 message: diag.message.clone(),
3747 severity: diag.severity.map(|s| match s {
3748 lsp_types::DiagnosticSeverity::ERROR => 1,
3749 lsp_types::DiagnosticSeverity::WARNING => 2,
3750 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3751 lsp_types::DiagnosticSeverity::HINT => 4,
3752 _ => 0,
3753 }),
3754 range: JsRange {
3755 start: JsPosition {
3756 line: diag.range.start.line,
3757 character: diag.range.start.character,
3758 },
3759 end: JsPosition {
3760 line: diag.range.end.line,
3761 character: diag.range.end.character,
3762 },
3763 },
3764 source: diag.source.clone(),
3765 });
3766 }
3767 }
3768 result
3769 } else {
3770 Vec::new()
3771 };
3772 rquickjs_serde::to_value(ctx, &diagnostics)
3773 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3774 }
3775
3776 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3778 self.event_handlers
3779 .borrow()
3780 .get(&event_name)
3781 .cloned()
3782 .unwrap_or_default()
3783 .into_iter()
3784 .map(|h| h.handler_name)
3785 .collect()
3786 }
3787
3788 #[plugin_api(
3792 async_promise,
3793 js_name = "createVirtualBuffer",
3794 ts_return = "VirtualBufferResult"
3795 )]
3796 #[qjs(rename = "_createVirtualBufferStart")]
3797 pub fn create_virtual_buffer_start(
3798 &self,
3799 _ctx: rquickjs::Ctx<'_>,
3800 opts: fresh_core::api::CreateVirtualBufferOptions,
3801 ) -> rquickjs::Result<u64> {
3802 let id = self.alloc_request_id();
3803
3804 let entries: Vec<TextPropertyEntry> = opts
3806 .entries
3807 .unwrap_or_default()
3808 .into_iter()
3809 .map(|e| TextPropertyEntry {
3810 text: e.text,
3811 properties: e.properties.unwrap_or_default(),
3812 style: e.style,
3813 inline_overlays: e.inline_overlays.unwrap_or_default(),
3814 })
3815 .collect();
3816
3817 tracing::debug!(
3818 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3819 id
3820 );
3821 if let Ok(mut owners) = self.async_resource_owners.lock() {
3823 owners.insert(id, self.plugin_name.clone());
3824 }
3825 let _ = self
3826 .command_sender
3827 .send(PluginCommand::CreateVirtualBufferWithContent {
3828 name: opts.name,
3829 mode: opts.mode.unwrap_or_default(),
3830 read_only: opts.read_only.unwrap_or(false),
3831 entries,
3832 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3833 show_cursors: opts.show_cursors.unwrap_or(true),
3834 editing_disabled: opts.editing_disabled.unwrap_or(false),
3835 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3836 request_id: Some(id),
3837 });
3838 Ok(id)
3839 }
3840
3841 #[plugin_api(
3843 async_promise,
3844 js_name = "createVirtualBufferInSplit",
3845 ts_return = "VirtualBufferResult"
3846 )]
3847 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3848 pub fn create_virtual_buffer_in_split_start(
3849 &self,
3850 _ctx: rquickjs::Ctx<'_>,
3851 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3852 ) -> rquickjs::Result<u64> {
3853 let id = self.alloc_request_id();
3854
3855 let entries: Vec<TextPropertyEntry> = opts
3857 .entries
3858 .unwrap_or_default()
3859 .into_iter()
3860 .map(|e| TextPropertyEntry {
3861 text: e.text,
3862 properties: e.properties.unwrap_or_default(),
3863 style: e.style,
3864 inline_overlays: e.inline_overlays.unwrap_or_default(),
3865 })
3866 .collect();
3867
3868 if let Ok(mut owners) = self.async_resource_owners.lock() {
3870 owners.insert(id, self.plugin_name.clone());
3871 }
3872 let _ = self
3873 .command_sender
3874 .send(PluginCommand::CreateVirtualBufferInSplit {
3875 name: opts.name,
3876 mode: opts.mode.unwrap_or_default(),
3877 read_only: opts.read_only.unwrap_or(false),
3878 entries,
3879 ratio: opts.ratio.unwrap_or(0.5),
3880 direction: opts.direction,
3881 panel_id: opts.panel_id,
3882 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3883 show_cursors: opts.show_cursors.unwrap_or(true),
3884 editing_disabled: opts.editing_disabled.unwrap_or(false),
3885 line_wrap: opts.line_wrap,
3886 before: opts.before.unwrap_or(false),
3887 role: opts.role,
3888 request_id: Some(id),
3889 });
3890 Ok(id)
3891 }
3892
3893 #[plugin_api(
3895 async_promise,
3896 js_name = "createVirtualBufferInExistingSplit",
3897 ts_return = "VirtualBufferResult"
3898 )]
3899 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3900 pub fn create_virtual_buffer_in_existing_split_start(
3901 &self,
3902 _ctx: rquickjs::Ctx<'_>,
3903 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3904 ) -> rquickjs::Result<u64> {
3905 let id = self.alloc_request_id();
3906
3907 let entries: Vec<TextPropertyEntry> = opts
3909 .entries
3910 .unwrap_or_default()
3911 .into_iter()
3912 .map(|e| TextPropertyEntry {
3913 text: e.text,
3914 properties: e.properties.unwrap_or_default(),
3915 style: e.style,
3916 inline_overlays: e.inline_overlays.unwrap_or_default(),
3917 })
3918 .collect();
3919
3920 if let Ok(mut owners) = self.async_resource_owners.lock() {
3922 owners.insert(id, self.plugin_name.clone());
3923 }
3924 let _ = self
3925 .command_sender
3926 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3927 name: opts.name,
3928 mode: opts.mode.unwrap_or_default(),
3929 read_only: opts.read_only.unwrap_or(false),
3930 entries,
3931 split_id: SplitId(opts.split_id),
3932 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3933 show_cursors: opts.show_cursors.unwrap_or(true),
3934 editing_disabled: opts.editing_disabled.unwrap_or(false),
3935 line_wrap: opts.line_wrap,
3936 request_id: Some(id),
3937 });
3938 Ok(id)
3939 }
3940
3941 #[qjs(rename = "_createBufferGroupStart")]
3943 pub fn create_buffer_group_start(
3944 &self,
3945 _ctx: rquickjs::Ctx<'_>,
3946 name: String,
3947 mode: String,
3948 layout_json: String,
3949 ) -> rquickjs::Result<u64> {
3950 let id = self.alloc_request_id();
3951 if let Ok(mut owners) = self.async_resource_owners.lock() {
3952 owners.insert(id, self.plugin_name.clone());
3953 }
3954 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
3955 name,
3956 mode,
3957 layout_json,
3958 request_id: Some(id),
3959 });
3960 Ok(id)
3961 }
3962
3963 #[qjs(rename = "setPanelContent")]
3965 pub fn set_panel_content<'js>(
3966 &self,
3967 ctx: rquickjs::Ctx<'js>,
3968 group_id: u32,
3969 panel_name: String,
3970 entries_arr: Vec<rquickjs::Object<'js>>,
3971 ) -> rquickjs::Result<bool> {
3972 let entries: Vec<TextPropertyEntry> = entries_arr
3973 .iter()
3974 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3975 .collect();
3976 Ok(self
3977 .command_sender
3978 .send(PluginCommand::SetPanelContent {
3979 group_id: group_id as usize,
3980 panel_name,
3981 entries,
3982 })
3983 .is_ok())
3984 }
3985
3986 #[qjs(rename = "closeBufferGroup")]
3988 pub fn close_buffer_group(&self, group_id: u32) -> bool {
3989 self.command_sender
3990 .send(PluginCommand::CloseBufferGroup {
3991 group_id: group_id as usize,
3992 })
3993 .is_ok()
3994 }
3995
3996 #[qjs(rename = "focusBufferGroupPanel")]
3998 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
3999 self.command_sender
4000 .send(PluginCommand::FocusPanel {
4001 group_id: group_id as usize,
4002 panel_name,
4003 })
4004 .is_ok()
4005 }
4006
4007 pub fn set_virtual_buffer_content<'js>(
4011 &self,
4012 ctx: rquickjs::Ctx<'js>,
4013 buffer_id: u32,
4014 entries_arr: Vec<rquickjs::Object<'js>>,
4015 ) -> rquickjs::Result<bool> {
4016 let entries: Vec<TextPropertyEntry> = entries_arr
4017 .iter()
4018 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
4019 .collect();
4020 Ok(self
4021 .command_sender
4022 .send(PluginCommand::SetVirtualBufferContent {
4023 buffer_id: BufferId(buffer_id as usize),
4024 entries,
4025 })
4026 .is_ok())
4027 }
4028
4029 pub fn get_text_properties_at_cursor(
4031 &self,
4032 buffer_id: u32,
4033 ) -> fresh_core::api::TextPropertiesAtCursor {
4034 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
4035 }
4036
4037 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
4041 #[qjs(rename = "_spawnProcessStart")]
4042 pub fn spawn_process_start(
4043 &self,
4044 _ctx: rquickjs::Ctx<'_>,
4045 command: String,
4046 args: Vec<String>,
4047 cwd: rquickjs::function::Opt<String>,
4048 ) -> u64 {
4049 let id = self.alloc_request_id();
4050 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
4056 self.state_snapshot
4057 .read()
4058 .ok()
4059 .map(|s| s.working_dir.to_string_lossy().to_string())
4060 });
4061 tracing::info!(
4062 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
4063 self.plugin_name,
4064 command,
4065 args,
4066 effective_cwd,
4067 id
4068 );
4069 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
4070 callback_id: JsCallbackId::new(id),
4071 command,
4072 args,
4073 cwd: effective_cwd,
4074 });
4075 id
4076 }
4077
4078 #[plugin_api(
4085 async_thenable,
4086 js_name = "spawnHostProcess",
4087 ts_return = "SpawnResult"
4088 )]
4089 #[qjs(rename = "_spawnHostProcessStart")]
4090 pub fn spawn_host_process_start(
4091 &self,
4092 _ctx: rquickjs::Ctx<'_>,
4093 command: String,
4094 args: Vec<String>,
4095 cwd: rquickjs::function::Opt<String>,
4096 ) -> u64 {
4097 let id = self.alloc_request_id();
4098 let effective_cwd = cwd.0.or_else(|| {
4099 self.state_snapshot
4100 .read()
4101 .ok()
4102 .map(|s| s.working_dir.to_string_lossy().to_string())
4103 });
4104 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
4105 callback_id: JsCallbackId::new(id),
4106 command,
4107 args,
4108 cwd: effective_cwd,
4109 });
4110 id
4111 }
4112
4113 #[plugin_api(js_name = "_killHostProcess")]
4123 pub fn kill_host_process(&self, process_id: u64) -> bool {
4124 self.command_sender
4125 .send(PluginCommand::KillHostProcess { process_id })
4126 .is_ok()
4127 }
4128
4129 #[plugin_api(js_name = "setAuthority")]
4138 pub fn set_authority(
4139 &self,
4140 ctx: rquickjs::Ctx<'_>,
4141 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
4142 ) -> bool {
4143 let json = js_to_json(&ctx, payload);
4144 let _ = self
4145 .command_sender
4146 .send(PluginCommand::SetAuthority { payload: json });
4147 true
4148 }
4149
4150 #[plugin_api(js_name = "clearAuthority")]
4153 pub fn clear_authority(&self) {
4154 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
4155 }
4156
4157 #[plugin_api(js_name = "setRemoteIndicatorState")]
4175 pub fn set_remote_indicator_state(
4176 &self,
4177 ctx: rquickjs::Ctx<'_>,
4178 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
4179 ) -> bool {
4180 let json = js_to_json(&ctx, state);
4181 let _ = self
4182 .command_sender
4183 .send(PluginCommand::SetRemoteIndicatorState { state: json });
4184 true
4185 }
4186
4187 #[plugin_api(js_name = "clearRemoteIndicatorState")]
4190 pub fn clear_remote_indicator_state(&self) {
4191 let _ = self
4192 .command_sender
4193 .send(PluginCommand::ClearRemoteIndicatorState);
4194 }
4195
4196 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
4198 #[qjs(rename = "_spawnProcessWaitStart")]
4199 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
4200 let id = self.alloc_request_id();
4201 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
4202 process_id,
4203 callback_id: JsCallbackId::new(id),
4204 });
4205 id
4206 }
4207
4208 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
4210 #[qjs(rename = "_getBufferTextStart")]
4211 pub fn get_buffer_text_start(
4212 &self,
4213 _ctx: rquickjs::Ctx<'_>,
4214 buffer_id: u32,
4215 start: u32,
4216 end: u32,
4217 ) -> u64 {
4218 let id = self.alloc_request_id();
4219 let _ = self.command_sender.send(PluginCommand::GetBufferText {
4220 buffer_id: BufferId(buffer_id as usize),
4221 start: start as usize,
4222 end: end as usize,
4223 request_id: id,
4224 });
4225 id
4226 }
4227
4228 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
4230 #[qjs(rename = "_delayStart")]
4231 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
4232 let id = self.alloc_request_id();
4233 let _ = self.command_sender.send(PluginCommand::Delay {
4234 callback_id: JsCallbackId::new(id),
4235 duration_ms,
4236 });
4237 id
4238 }
4239
4240 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
4244 #[qjs(rename = "_grepProjectStart")]
4245 pub fn grep_project_start(
4246 &self,
4247 _ctx: rquickjs::Ctx<'_>,
4248 pattern: String,
4249 fixed_string: Option<bool>,
4250 case_sensitive: Option<bool>,
4251 max_results: Option<u32>,
4252 whole_words: Option<bool>,
4253 ) -> u64 {
4254 let id = self.alloc_request_id();
4255 let _ = self.command_sender.send(PluginCommand::GrepProject {
4256 pattern,
4257 fixed_string: fixed_string.unwrap_or(true),
4258 case_sensitive: case_sensitive.unwrap_or(true),
4259 max_results: max_results.unwrap_or(200) as usize,
4260 whole_words: whole_words.unwrap_or(false),
4261 callback_id: JsCallbackId::new(id),
4262 });
4263 id
4264 }
4265
4266 #[plugin_api(
4270 js_name = "grepProjectStreaming",
4271 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
4272 )]
4273 #[qjs(rename = "_grepProjectStreamingStart")]
4274 pub fn grep_project_streaming_start(
4275 &self,
4276 _ctx: rquickjs::Ctx<'_>,
4277 pattern: String,
4278 fixed_string: bool,
4279 case_sensitive: bool,
4280 max_results: u32,
4281 whole_words: bool,
4282 ) -> u64 {
4283 let id = self.alloc_request_id();
4284 let _ = self
4285 .command_sender
4286 .send(PluginCommand::GrepProjectStreaming {
4287 pattern,
4288 fixed_string,
4289 case_sensitive,
4290 max_results: max_results as usize,
4291 whole_words,
4292 search_id: id,
4293 callback_id: JsCallbackId::new(id),
4294 });
4295 id
4296 }
4297
4298 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
4302 #[qjs(rename = "_replaceInFileStart")]
4303 pub fn replace_in_file_start(
4304 &self,
4305 _ctx: rquickjs::Ctx<'_>,
4306 file_path: String,
4307 matches: Vec<Vec<u32>>,
4308 replacement: String,
4309 ) -> u64 {
4310 let id = self.alloc_request_id();
4311 let match_pairs: Vec<(usize, usize)> = matches
4313 .iter()
4314 .map(|m| (m[0] as usize, m[1] as usize))
4315 .collect();
4316 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
4317 file_path: PathBuf::from(file_path),
4318 matches: match_pairs,
4319 replacement,
4320 callback_id: JsCallbackId::new(id),
4321 });
4322 id
4323 }
4324
4325 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
4327 #[qjs(rename = "_sendLspRequestStart")]
4328 pub fn send_lsp_request_start<'js>(
4329 &self,
4330 ctx: rquickjs::Ctx<'js>,
4331 language: String,
4332 method: String,
4333 params: Option<rquickjs::Object<'js>>,
4334 ) -> rquickjs::Result<u64> {
4335 let id = self.alloc_request_id();
4336 let params_json: Option<serde_json::Value> = params.map(|obj| {
4338 let val = obj.into_value();
4339 js_to_json(&ctx, val)
4340 });
4341 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
4342 request_id: id,
4343 language,
4344 method,
4345 params: params_json,
4346 });
4347 Ok(id)
4348 }
4349
4350 #[plugin_api(
4352 async_thenable,
4353 js_name = "spawnBackgroundProcess",
4354 ts_return = "BackgroundProcessResult"
4355 )]
4356 #[qjs(rename = "_spawnBackgroundProcessStart")]
4357 pub fn spawn_background_process_start(
4358 &self,
4359 _ctx: rquickjs::Ctx<'_>,
4360 command: String,
4361 args: Vec<String>,
4362 cwd: rquickjs::function::Opt<String>,
4363 ) -> u64 {
4364 let id = self.alloc_request_id();
4365 let process_id = id;
4367 self.plugin_tracked_state
4369 .borrow_mut()
4370 .entry(self.plugin_name.clone())
4371 .or_default()
4372 .background_process_ids
4373 .push(process_id);
4374 let _ = self
4376 .command_sender
4377 .send(PluginCommand::SpawnBackgroundProcess {
4378 process_id,
4379 command,
4380 args,
4381 cwd: cwd.0.filter(|s| !s.is_empty()),
4382 callback_id: JsCallbackId::new(id),
4383 });
4384 id
4385 }
4386
4387 pub fn kill_background_process(&self, process_id: u64) -> bool {
4389 self.command_sender
4390 .send(PluginCommand::KillBackgroundProcess { process_id })
4391 .is_ok()
4392 }
4393
4394 #[plugin_api(
4398 async_promise,
4399 js_name = "createTerminal",
4400 ts_return = "TerminalResult"
4401 )]
4402 #[qjs(rename = "_createTerminalStart")]
4403 pub fn create_terminal_start(
4404 &self,
4405 _ctx: rquickjs::Ctx<'_>,
4406 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
4407 ) -> rquickjs::Result<u64> {
4408 let id = self.alloc_request_id();
4409
4410 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
4411 cwd: None,
4412 direction: None,
4413 ratio: None,
4414 focus: None,
4415 persistent: None,
4416 });
4417
4418 if let Ok(mut owners) = self.async_resource_owners.lock() {
4420 owners.insert(id, self.plugin_name.clone());
4421 }
4422 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
4423 cwd: opts.cwd,
4424 direction: opts.direction,
4425 ratio: opts.ratio,
4426 focus: opts.focus,
4427 persistent: opts.persistent.unwrap_or(false),
4431 request_id: id,
4432 });
4433 Ok(id)
4434 }
4435
4436 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
4438 self.command_sender
4439 .send(PluginCommand::SendTerminalInput {
4440 terminal_id: fresh_core::TerminalId(terminal_id as usize),
4441 data,
4442 })
4443 .is_ok()
4444 }
4445
4446 pub fn close_terminal(&self, terminal_id: u64) -> bool {
4448 self.command_sender
4449 .send(PluginCommand::CloseTerminal {
4450 terminal_id: fresh_core::TerminalId(terminal_id as usize),
4451 })
4452 .is_ok()
4453 }
4454
4455 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
4459 self.command_sender
4460 .send(PluginCommand::RefreshLines {
4461 buffer_id: BufferId(buffer_id as usize),
4462 })
4463 .is_ok()
4464 }
4465
4466 pub fn get_current_locale(&self) -> String {
4468 self.services.current_locale()
4469 }
4470
4471 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
4475 #[qjs(rename = "_loadPluginStart")]
4476 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
4477 let id = self.alloc_request_id();
4478 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
4479 path: std::path::PathBuf::from(path),
4480 callback_id: JsCallbackId::new(id),
4481 });
4482 id
4483 }
4484
4485 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
4487 #[qjs(rename = "_unloadPluginStart")]
4488 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4489 let id = self.alloc_request_id();
4490 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
4491 name,
4492 callback_id: JsCallbackId::new(id),
4493 });
4494 id
4495 }
4496
4497 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
4499 #[qjs(rename = "_reloadPluginStart")]
4500 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4501 let id = self.alloc_request_id();
4502 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
4503 name,
4504 callback_id: JsCallbackId::new(id),
4505 });
4506 id
4507 }
4508
4509 #[plugin_api(
4512 async_promise,
4513 js_name = "listPlugins",
4514 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
4515 )]
4516 #[qjs(rename = "_listPluginsStart")]
4517 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4518 let id = self.alloc_request_id();
4519 let _ = self.command_sender.send(PluginCommand::ListPlugins {
4520 callback_id: JsCallbackId::new(id),
4521 });
4522 id
4523 }
4524}
4525
4526fn parse_view_token(
4533 obj: &rquickjs::Object<'_>,
4534 idx: usize,
4535) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
4536 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4537
4538 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
4540 from: "object",
4541 to: "ViewTokenWire",
4542 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
4543 })?;
4544
4545 let source_offset: Option<usize> = obj
4547 .get("sourceOffset")
4548 .ok()
4549 .or_else(|| obj.get("source_offset").ok());
4550
4551 let kind = if kind_value.is_string() {
4553 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4556 from: "value",
4557 to: "string",
4558 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
4559 })?;
4560
4561 match kind_str.to_lowercase().as_str() {
4562 "text" => {
4563 let text: String = obj.get("text").unwrap_or_default();
4564 ViewTokenWireKind::Text(text)
4565 }
4566 "newline" => ViewTokenWireKind::Newline,
4567 "space" => ViewTokenWireKind::Space,
4568 "break" => ViewTokenWireKind::Break,
4569 _ => {
4570 tracing::warn!(
4572 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
4573 idx, kind_str
4574 );
4575 return Err(rquickjs::Error::FromJs {
4576 from: "string",
4577 to: "ViewTokenWireKind",
4578 message: Some(format!(
4579 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
4580 idx, kind_str
4581 )),
4582 });
4583 }
4584 }
4585 } else if kind_value.is_object() {
4586 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4588 from: "value",
4589 to: "object",
4590 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
4591 })?;
4592
4593 if let Ok(text) = kind_obj.get::<_, String>("Text") {
4594 ViewTokenWireKind::Text(text)
4595 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
4596 ViewTokenWireKind::BinaryByte(byte)
4597 } else {
4598 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
4600 tracing::warn!(
4601 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
4602 idx,
4603 keys
4604 );
4605 return Err(rquickjs::Error::FromJs {
4606 from: "object",
4607 to: "ViewTokenWireKind",
4608 message: Some(format!(
4609 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
4610 idx, keys
4611 )),
4612 });
4613 }
4614 } else {
4615 tracing::warn!(
4616 "token[{}]: 'kind' field must be a string or object, got: {:?}",
4617 idx,
4618 kind_value.type_of()
4619 );
4620 return Err(rquickjs::Error::FromJs {
4621 from: "value",
4622 to: "ViewTokenWireKind",
4623 message: Some(format!(
4624 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
4625 idx
4626 )),
4627 });
4628 };
4629
4630 let style = parse_view_token_style(obj, idx)?;
4632
4633 Ok(ViewTokenWire {
4634 source_offset,
4635 kind,
4636 style,
4637 })
4638}
4639
4640fn parse_view_token_style(
4642 obj: &rquickjs::Object<'_>,
4643 idx: usize,
4644) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
4645 use fresh_core::api::ViewTokenStyle;
4646
4647 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
4648 let Some(s) = style_obj else {
4649 return Ok(None);
4650 };
4651
4652 let fg: Option<Vec<u8>> = s.get("fg").ok();
4653 let bg: Option<Vec<u8>> = s.get("bg").ok();
4654
4655 let fg_color = if let Some(ref c) = fg {
4657 if c.len() < 3 {
4658 tracing::warn!(
4659 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4660 idx,
4661 c.len()
4662 );
4663 None
4664 } else {
4665 Some((c[0], c[1], c[2]))
4666 }
4667 } else {
4668 None
4669 };
4670
4671 let bg_color = if let Some(ref c) = bg {
4672 if c.len() < 3 {
4673 tracing::warn!(
4674 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4675 idx,
4676 c.len()
4677 );
4678 None
4679 } else {
4680 Some((c[0], c[1], c[2]))
4681 }
4682 } else {
4683 None
4684 };
4685
4686 Ok(Some(ViewTokenStyle {
4687 fg: fg_color,
4688 bg: bg_color,
4689 bold: s.get("bold").unwrap_or(false),
4690 italic: s.get("italic").unwrap_or(false),
4691 }))
4692}
4693
4694pub struct QuickJsBackend {
4696 runtime: Runtime,
4697 main_context: Context,
4699 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4701 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4703 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4705 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4707 command_sender: mpsc::Sender<PluginCommand>,
4709 #[allow(dead_code)]
4711 pending_responses: PendingResponses,
4712 next_request_id: Rc<RefCell<u64>>,
4714 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4716 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4718 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4720 async_resource_owners: AsyncResourceOwners,
4723 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4725 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4727 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4729 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4731 plugin_api_exports:
4735 Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>,
4736}
4737
4738impl Drop for QuickJsBackend {
4739 fn drop(&mut self) {
4740 self.plugin_api_exports.borrow_mut().clear();
4746 }
4747}
4748
4749impl QuickJsBackend {
4750 pub fn new() -> Result<Self> {
4752 let (tx, _rx) = mpsc::channel();
4753 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4754 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4755 Self::with_state(state_snapshot, tx, services)
4756 }
4757
4758 pub fn with_state(
4760 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4761 command_sender: mpsc::Sender<PluginCommand>,
4762 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4763 ) -> Result<Self> {
4764 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4765 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4766 }
4767
4768 pub fn with_state_and_responses(
4770 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4771 command_sender: mpsc::Sender<PluginCommand>,
4772 pending_responses: PendingResponses,
4773 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4774 ) -> Result<Self> {
4775 let async_resource_owners: AsyncResourceOwners =
4776 Arc::new(std::sync::Mutex::new(HashMap::new()));
4777 Self::with_state_responses_and_resources(
4778 state_snapshot,
4779 command_sender,
4780 pending_responses,
4781 services,
4782 async_resource_owners,
4783 )
4784 }
4785
4786 pub fn with_state_responses_and_resources(
4789 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4790 command_sender: mpsc::Sender<PluginCommand>,
4791 pending_responses: PendingResponses,
4792 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4793 async_resource_owners: AsyncResourceOwners,
4794 ) -> Result<Self> {
4795 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4796
4797 let runtime =
4798 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4799
4800 runtime.set_host_promise_rejection_tracker(Some(Box::new(
4802 |_ctx, _promise, reason, is_handled| {
4803 if !is_handled {
4804 let error_msg = if let Some(exc) = reason.as_exception() {
4806 format!(
4807 "{}: {}",
4808 exc.message().unwrap_or_default(),
4809 exc.stack().unwrap_or_default()
4810 )
4811 } else {
4812 format!("{:?}", reason)
4813 };
4814
4815 tracing::error!("Unhandled Promise rejection: {}", error_msg);
4816
4817 if should_panic_on_js_errors() {
4818 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4821 set_fatal_js_error(full_msg);
4822 }
4823 }
4824 },
4825 )));
4826
4827 let main_context = Context::full(&runtime)
4828 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4829
4830 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4831 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4832 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4833 let next_request_id = Rc::new(RefCell::new(1u64));
4834 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4835 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4836 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4837 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4838 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4839 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4840 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
4841
4842 let backend = Self {
4843 runtime,
4844 main_context,
4845 plugin_contexts,
4846 event_handlers,
4847 registered_actions,
4848 state_snapshot,
4849 command_sender,
4850 pending_responses,
4851 next_request_id,
4852 callback_contexts,
4853 services,
4854 plugin_tracked_state,
4855 async_resource_owners,
4856 registered_command_names,
4857 registered_grammar_languages,
4858 registered_language_configs,
4859 registered_lsp_servers,
4860 plugin_api_exports,
4861 };
4862
4863 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4865
4866 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4867 Ok(backend)
4868 }
4869
4870 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4872 let state_snapshot = Arc::clone(&self.state_snapshot);
4873 let command_sender = self.command_sender.clone();
4874 let event_handlers = Rc::clone(&self.event_handlers);
4875 let registered_actions = Rc::clone(&self.registered_actions);
4876 let next_request_id = Rc::clone(&self.next_request_id);
4877 let registered_command_names = Rc::clone(&self.registered_command_names);
4878 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4879 let registered_language_configs = Rc::clone(&self.registered_language_configs);
4880 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4881 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
4882
4883 context.with(|ctx| {
4884 let globals = ctx.globals();
4885
4886 globals.set("__pluginName__", plugin_name)?;
4888
4889 let js_api = JsEditorApi {
4892 state_snapshot: Arc::clone(&state_snapshot),
4893 command_sender: command_sender.clone(),
4894 registered_actions: Rc::clone(®istered_actions),
4895 event_handlers: Rc::clone(&event_handlers),
4896 next_request_id: Rc::clone(&next_request_id),
4897 callback_contexts: Rc::clone(&self.callback_contexts),
4898 services: self.services.clone(),
4899 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4900 async_resource_owners: Arc::clone(&self.async_resource_owners),
4901 registered_command_names: Rc::clone(®istered_command_names),
4902 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
4903 registered_language_configs: Rc::clone(®istered_language_configs),
4904 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
4905 plugin_api_exports: Rc::clone(&plugin_api_exports),
4906 plugin_name: plugin_name.to_string(),
4907 };
4908 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4909
4910 globals.set("editor", editor)?;
4912
4913 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4915
4916 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4918
4919ctx.eval::<(), _>(
4926 r#"
4927 (function() {
4928 const originalOn = editor.on.bind(editor);
4929 const originalOff = editor.off.bind(editor);
4930 let counter = 0;
4931 const anonNames = new WeakMap();
4932 editor.on = function(eventName, handlerOrName) {
4933 if (typeof handlerOrName === 'function') {
4934 const existing = anonNames.get(handlerOrName);
4935 const name = existing || `__anon_on_${++counter}`;
4936 if (!existing) {
4937 anonNames.set(handlerOrName, name);
4938 }
4939 globalThis[name] = handlerOrName;
4940 return originalOn(eventName, name);
4941 }
4942 return originalOn(eventName, handlerOrName);
4943 };
4944 editor.off = function(eventName, handlerOrName) {
4945 if (typeof handlerOrName === 'function') {
4946 const name = anonNames.get(handlerOrName);
4947 if (name === undefined) return false;
4948 return originalOff(eventName, name);
4949 }
4950 return originalOff(eventName, handlerOrName);
4951 };
4952 })();
4953 "#,
4954 )?;
4955
4956 let console = Object::new(ctx.clone())?;
4959 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4960 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4961 tracing::info!("console.log: {}", parts.join(" "));
4962 })?)?;
4963 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4964 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4965 tracing::warn!("console.warn: {}", parts.join(" "));
4966 })?)?;
4967 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4968 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4969 tracing::error!("console.error: {}", parts.join(" "));
4970 })?)?;
4971 globals.set("console", console)?;
4972
4973 ctx.eval::<(), _>(r#"
4975 // Pending promise callbacks: callbackId -> { resolve, reject }
4976 globalThis._pendingCallbacks = new Map();
4977
4978 // Resolve a pending callback (called from Rust)
4979 globalThis._resolveCallback = function(callbackId, result) {
4980 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4981 const cb = globalThis._pendingCallbacks.get(callbackId);
4982 if (cb) {
4983 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4984 globalThis._pendingCallbacks.delete(callbackId);
4985 cb.resolve(result);
4986 console.log('[JS] _resolveCallback: resolve() called');
4987 } else {
4988 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4989 }
4990 };
4991
4992 // Reject a pending callback (called from Rust)
4993 globalThis._rejectCallback = function(callbackId, error) {
4994 const cb = globalThis._pendingCallbacks.get(callbackId);
4995 if (cb) {
4996 globalThis._pendingCallbacks.delete(callbackId);
4997 cb.reject(new Error(error));
4998 }
4999 };
5000
5001 // Streaming callbacks: called multiple times with partial results
5002 globalThis._streamingCallbacks = new Map();
5003
5004 // Called from Rust with partial data. When done=true, cleans up.
5005 globalThis._callStreamingCallback = function(callbackId, result, done) {
5006 const cb = globalThis._streamingCallbacks.get(callbackId);
5007 if (cb) {
5008 cb(result, done);
5009 if (done) {
5010 globalThis._streamingCallbacks.delete(callbackId);
5011 }
5012 }
5013 };
5014
5015 // Generic async wrapper decorator
5016 // Wraps a function that returns a callbackId into a promise-returning function
5017 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
5018 // NOTE: We pass the method name as a string and call via bracket notation
5019 // to preserve rquickjs's automatic Ctx injection for methods
5020 globalThis._wrapAsync = function(methodName, fnName) {
5021 const startFn = editor[methodName];
5022 if (typeof startFn !== 'function') {
5023 // Return a function that always throws - catches missing implementations
5024 return function(...args) {
5025 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
5026 editor.debug(`[ASYNC ERROR] ${error.message}`);
5027 throw error;
5028 };
5029 }
5030 return function(...args) {
5031 // Call via bracket notation to preserve method binding and Ctx injection
5032 const callbackId = editor[methodName](...args);
5033 return new Promise((resolve, reject) => {
5034 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
5035 // TODO: Implement setTimeout polyfill using editor.delay() or similar
5036 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
5037 });
5038 };
5039 };
5040
5041 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
5042 // The returned object has .result promise and is itself thenable
5043 globalThis._wrapAsyncThenable = function(methodName, fnName) {
5044 const startFn = editor[methodName];
5045 if (typeof startFn !== 'function') {
5046 // Return a function that always throws - catches missing implementations
5047 return function(...args) {
5048 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
5049 editor.debug(`[ASYNC ERROR] ${error.message}`);
5050 throw error;
5051 };
5052 }
5053 return function(...args) {
5054 // Call via bracket notation to preserve method binding and Ctx injection
5055 const callbackId = editor[methodName](...args);
5056 const resultPromise = new Promise((resolve, reject) => {
5057 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
5058 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
5059 });
5060 return {
5061 get result() { return resultPromise; },
5062 then(onFulfilled, onRejected) {
5063 return resultPromise.then(onFulfilled, onRejected);
5064 },
5065 catch(onRejected) {
5066 return resultPromise.catch(onRejected);
5067 }
5068 };
5069 };
5070 };
5071
5072 // Apply wrappers to async functions on editor
5073 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
5074 // spawnHostProcess gets a bespoke wrapper (instead of
5075 // `_wrapAsyncThenable`) because its `ProcessHandle`
5076 // exposes a real `kill()` that forwards to
5077 // `_killHostProcess`. Generic wrap has no hook for
5078 // that.
5079 editor.spawnHostProcess = function(command, args, cwd) {
5080 if (typeof editor._spawnHostProcessStart !== 'function') {
5081 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
5082 }
5083 // Pass real strings only. Earlier revisions forwarded
5084 // `""` for a missing cwd, which landed verbatim as
5085 // `Command::current_dir("")` in the dispatcher —
5086 // every host-spawn then failed with ENOENT. Use two
5087 // arity forms so the Rust `Opt<String>` stays `None`
5088 // instead of `Some("")`.
5089 let callbackId;
5090 if (typeof cwd === "string" && cwd.length > 0) {
5091 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
5092 } else {
5093 callbackId = editor._spawnHostProcessStart(command, args || []);
5094 }
5095 const resultPromise = new Promise(function(resolve, reject) {
5096 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
5097 });
5098 return {
5099 processId: callbackId,
5100 get result() { return resultPromise; },
5101 then: function(f, r) { return resultPromise.then(f, r); },
5102 catch: function(r) { return resultPromise.catch(r); },
5103 kill: function() {
5104 // Returns true when the kill was enqueued
5105 // (the process may have already exited; in
5106 // that case the dispatcher silently
5107 // drops it). Matches the
5108 // `ProcessHandle.kill(): Promise<boolean>`
5109 // type signature by wrapping the sync
5110 // boolean in a Promise.
5111 return Promise.resolve(editor._killHostProcess(callbackId));
5112 }
5113 };
5114 };
5115 editor.delay = _wrapAsync("_delayStart", "delay");
5116 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
5117 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
5118 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
5119 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
5120 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
5121 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
5122 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
5123 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
5124 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
5125 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
5126 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
5127 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
5128 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
5129 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
5130 editor.prompt = _wrapAsync("_promptStart", "prompt");
5131 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
5132 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
5133 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
5134 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
5135 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
5136 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
5137 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
5138
5139 // Streaming grep: takes a progress callback, returns a thenable with searchId
5140 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
5141 opts = opts || {};
5142 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
5143 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
5144 const maxResults = opts.maxResults || 10000;
5145 const wholeWords = opts.wholeWords || false;
5146
5147 const searchId = editor._grepProjectStreamingStart(
5148 pattern, fixedString, caseSensitive, maxResults, wholeWords
5149 );
5150
5151 // Register streaming callback
5152 if (progressCallback) {
5153 globalThis._streamingCallbacks.set(searchId, progressCallback);
5154 }
5155
5156 // Create completion promise (resolved via _resolveCallback when search finishes)
5157 const resultPromise = new Promise(function(resolve, reject) {
5158 globalThis._pendingCallbacks.set(searchId, {
5159 resolve: function(result) {
5160 globalThis._streamingCallbacks.delete(searchId);
5161 resolve(result);
5162 },
5163 reject: function(err) {
5164 globalThis._streamingCallbacks.delete(searchId);
5165 reject(err);
5166 }
5167 });
5168 });
5169
5170 return {
5171 searchId: searchId,
5172 get result() { return resultPromise; },
5173 then: function(f, r) { return resultPromise.then(f, r); },
5174 catch: function(r) { return resultPromise.catch(r); }
5175 };
5176 };
5177
5178 // Wrapper for deleteTheme - wraps sync function in Promise
5179 editor.deleteTheme = function(name) {
5180 return new Promise(function(resolve, reject) {
5181 const success = editor._deleteThemeSync(name);
5182 if (success) {
5183 resolve();
5184 } else {
5185 reject(new Error("Failed to delete theme: " + name));
5186 }
5187 });
5188 };
5189 "#.as_bytes())?;
5190
5191 Ok::<_, rquickjs::Error>(())
5192 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
5193
5194 Ok(())
5195 }
5196
5197 pub async fn load_module_with_source(
5199 &mut self,
5200 path: &str,
5201 _plugin_source: &str,
5202 ) -> Result<()> {
5203 let path_buf = PathBuf::from(path);
5204 let source = std::fs::read_to_string(&path_buf)
5205 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
5206
5207 let filename = path_buf
5208 .file_name()
5209 .and_then(|s| s.to_str())
5210 .unwrap_or("plugin.ts");
5211
5212 if has_es_imports(&source) {
5214 match bundle_module(&path_buf) {
5216 Ok(bundled) => {
5217 self.execute_js(&bundled, path)?;
5218 }
5219 Err(e) => {
5220 tracing::warn!(
5221 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
5222 path,
5223 e
5224 );
5225 return Ok(()); }
5227 }
5228 } else if has_es_module_syntax(&source) {
5229 let stripped = strip_imports_and_exports(&source);
5231 let js_code = if filename.ends_with(".ts") {
5232 transpile_typescript(&stripped, filename)?
5233 } else {
5234 stripped
5235 };
5236 self.execute_js(&js_code, path)?;
5237 } else {
5238 let js_code = if filename.ends_with(".ts") {
5240 transpile_typescript(&source, filename)?
5241 } else {
5242 source
5243 };
5244 self.execute_js(&js_code, path)?;
5245 }
5246
5247 Ok(())
5248 }
5249
5250 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
5252 let plugin_name = Path::new(source_name)
5254 .file_stem()
5255 .and_then(|s| s.to_str())
5256 .unwrap_or("unknown");
5257
5258 tracing::debug!(
5259 "execute_js: starting for plugin '{}' from '{}'",
5260 plugin_name,
5261 source_name
5262 );
5263
5264 let context = {
5266 let mut contexts = self.plugin_contexts.borrow_mut();
5267 if let Some(ctx) = contexts.get(plugin_name) {
5268 ctx.clone()
5269 } else {
5270 let ctx = Context::full(&self.runtime).map_err(|e| {
5271 anyhow!(
5272 "Failed to create QuickJS context for plugin {}: {}",
5273 plugin_name,
5274 e
5275 )
5276 })?;
5277 self.setup_context_api(&ctx, plugin_name)?;
5278 contexts.insert(plugin_name.to_string(), ctx.clone());
5279 ctx
5280 }
5281 };
5282
5283 let wrapped_code = format!("(function() {{ {} }})();", code);
5287 let wrapped = wrapped_code.as_str();
5288
5289 context.with(|ctx| {
5290 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
5291
5292 let mut eval_options = rquickjs::context::EvalOptions::default();
5294 eval_options.global = true;
5295 eval_options.filename = Some(source_name.to_string());
5296 let result = ctx
5297 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
5298 .map_err(|e| format_js_error(&ctx, e, source_name));
5299
5300 tracing::debug!(
5301 "execute_js: plugin code execution finished for '{}', result: {:?}",
5302 plugin_name,
5303 result.is_ok()
5304 );
5305
5306 result
5307 })
5308 }
5309
5310 pub fn execute_source(
5316 &mut self,
5317 source: &str,
5318 plugin_name: &str,
5319 is_typescript: bool,
5320 ) -> Result<()> {
5321 use fresh_parser_js::{
5322 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
5323 };
5324
5325 if has_es_imports(source) {
5326 tracing::warn!(
5327 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
5328 plugin_name
5329 );
5330 }
5331
5332 let js_code = if has_es_module_syntax(source) {
5333 let stripped = strip_imports_and_exports(source);
5334 if is_typescript {
5335 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
5336 } else {
5337 stripped
5338 }
5339 } else if is_typescript {
5340 transpile_typescript(source, &format!("{}.ts", plugin_name))?
5341 } else {
5342 source.to_string()
5343 };
5344
5345 let source_name = format!(
5347 "{}.{}",
5348 plugin_name,
5349 if is_typescript { "ts" } else { "js" }
5350 );
5351 self.execute_js(&js_code, &source_name)
5352 }
5353
5354 pub fn cleanup_plugin(&self, plugin_name: &str) {
5360 self.plugin_contexts.borrow_mut().remove(plugin_name);
5362
5363 for handlers in self.event_handlers.borrow_mut().values_mut() {
5365 handlers.retain(|h| h.plugin_name != plugin_name);
5366 }
5367
5368 self.registered_actions
5370 .borrow_mut()
5371 .retain(|_, h| h.plugin_name != plugin_name);
5372
5373 self.callback_contexts
5375 .borrow_mut()
5376 .retain(|_, pname| pname != plugin_name);
5377
5378 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
5380 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
5382 std::collections::HashSet::new();
5383 for (buf_id, ns) in &tracked.overlay_namespaces {
5384 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
5385 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
5387 buffer_id: *buf_id,
5388 namespace: OverlayNamespace::from_string(ns.clone()),
5389 });
5390 let _ = self
5392 .command_sender
5393 .send(PluginCommand::ClearConcealNamespace {
5394 buffer_id: *buf_id,
5395 namespace: OverlayNamespace::from_string(ns.clone()),
5396 });
5397 let _ = self
5398 .command_sender
5399 .send(PluginCommand::ClearSoftBreakNamespace {
5400 buffer_id: *buf_id,
5401 namespace: OverlayNamespace::from_string(ns.clone()),
5402 });
5403 }
5404 }
5405
5406 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
5412 std::collections::HashSet::new();
5413 for (buf_id, ns) in &tracked.line_indicator_namespaces {
5414 if seen_li_ns.insert((buf_id.0, ns.clone())) {
5415 let _ = self
5416 .command_sender
5417 .send(PluginCommand::ClearLineIndicators {
5418 buffer_id: *buf_id,
5419 namespace: ns.clone(),
5420 });
5421 }
5422 }
5423
5424 let mut seen_vt: std::collections::HashSet<(usize, String)> =
5426 std::collections::HashSet::new();
5427 for (buf_id, vt_id) in &tracked.virtual_text_ids {
5428 if seen_vt.insert((buf_id.0, vt_id.clone())) {
5429 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
5430 buffer_id: *buf_id,
5431 virtual_text_id: vt_id.clone(),
5432 });
5433 }
5434 }
5435
5436 let mut seen_fe_ns: std::collections::HashSet<String> =
5438 std::collections::HashSet::new();
5439 for ns in &tracked.file_explorer_namespaces {
5440 if seen_fe_ns.insert(ns.clone()) {
5441 let _ = self
5442 .command_sender
5443 .send(PluginCommand::ClearFileExplorerDecorations {
5444 namespace: ns.clone(),
5445 });
5446 }
5447 }
5448
5449 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
5451 for ctx_name in &tracked.contexts_set {
5452 if seen_ctx.insert(ctx_name.clone()) {
5453 let _ = self.command_sender.send(PluginCommand::SetContext {
5454 name: ctx_name.clone(),
5455 active: false,
5456 });
5457 }
5458 }
5459
5460 for process_id in &tracked.background_process_ids {
5464 let _ = self
5465 .command_sender
5466 .send(PluginCommand::KillBackgroundProcess {
5467 process_id: *process_id,
5468 });
5469 }
5470
5471 for group_id in &tracked.scroll_sync_group_ids {
5473 let _ = self
5474 .command_sender
5475 .send(PluginCommand::RemoveScrollSyncGroup {
5476 group_id: *group_id,
5477 });
5478 }
5479
5480 for buffer_id in &tracked.virtual_buffer_ids {
5482 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
5483 buffer_id: *buffer_id,
5484 });
5485 }
5486
5487 for buffer_id in &tracked.composite_buffer_ids {
5489 let _ = self
5490 .command_sender
5491 .send(PluginCommand::CloseCompositeBuffer {
5492 buffer_id: *buffer_id,
5493 });
5494 }
5495
5496 for terminal_id in &tracked.terminal_ids {
5498 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
5499 terminal_id: *terminal_id,
5500 });
5501 }
5502 }
5503
5504 if let Ok(mut owners) = self.async_resource_owners.lock() {
5506 owners.retain(|_, name| name != plugin_name);
5507 }
5508
5509 self.plugin_api_exports
5511 .borrow_mut()
5512 .retain(|_, (exporter, _)| exporter != plugin_name);
5513
5514 self.registered_command_names
5516 .borrow_mut()
5517 .retain(|_, pname| pname != plugin_name);
5518 self.registered_grammar_languages
5519 .borrow_mut()
5520 .retain(|_, pname| pname != plugin_name);
5521 self.registered_language_configs
5522 .borrow_mut()
5523 .retain(|_, pname| pname != plugin_name);
5524 self.registered_lsp_servers
5525 .borrow_mut()
5526 .retain(|_, pname| pname != plugin_name);
5527
5528 tracing::debug!(
5529 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
5530 plugin_name
5531 );
5532 }
5533
5534 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
5536 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
5537
5538 self.services
5539 .set_js_execution_state(format!("hook '{}'", event_name));
5540
5541 let handlers = self.event_handlers.borrow().get(event_name).cloned();
5542 if let Some(handler_pairs) = handlers {
5543 let plugin_contexts = self.plugin_contexts.borrow();
5544 for handler in &handler_pairs {
5545 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
5546 continue;
5547 };
5548 context.with(|ctx| {
5549 call_handler(&ctx, &handler.handler_name, event_data);
5550 });
5551 }
5552 }
5553
5554 self.services.clear_js_execution_state();
5555 Ok(true)
5556 }
5557
5558 pub fn has_handlers(&self, event_name: &str) -> bool {
5560 self.event_handlers
5561 .borrow()
5562 .get(event_name)
5563 .map(|v| !v.is_empty())
5564 .unwrap_or(false)
5565 }
5566
5567 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
5571 let (lookup_name, text_input_char) =
5574 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
5575 ("mode_text_input", Some(ch.to_string()))
5576 } else {
5577 (action_name, None)
5578 };
5579
5580 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
5581 let (plugin_name, function_name) = match pair {
5582 Some(handler) => (handler.plugin_name, handler.handler_name),
5583 None => ("main".to_string(), lookup_name.to_string()),
5584 };
5585
5586 let plugin_contexts = self.plugin_contexts.borrow();
5587 let context = plugin_contexts
5588 .get(&plugin_name)
5589 .unwrap_or(&self.main_context);
5590
5591 self.services
5593 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
5594
5595 tracing::info!(
5596 "start_action: BEGIN '{}' -> function '{}'",
5597 action_name,
5598 function_name
5599 );
5600
5601 let call_args = if let Some(ref ch) = text_input_char {
5604 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
5605 format!("({{text:\"{}\"}})", escaped)
5606 } else {
5607 "()".to_string()
5608 };
5609
5610 let code = format!(
5611 r#"
5612 (function() {{
5613 console.log('[JS] start_action: calling {fn}');
5614 try {{
5615 if (typeof globalThis.{fn} === 'function') {{
5616 console.log('[JS] start_action: {fn} is a function, invoking...');
5617 globalThis.{fn}{args};
5618 console.log('[JS] start_action: {fn} invoked (may be async)');
5619 }} else {{
5620 console.error('[JS] Action {action} is not defined as a global function');
5621 }}
5622 }} catch (e) {{
5623 console.error('[JS] Action {action} error:', e);
5624 }}
5625 }})();
5626 "#,
5627 fn = function_name,
5628 action = action_name,
5629 args = call_args
5630 );
5631
5632 tracing::info!("start_action: evaluating JS code");
5633 context.with(|ctx| {
5634 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5635 log_js_error(&ctx, e, &format!("action {}", action_name));
5636 }
5637 tracing::info!("start_action: running pending microtasks");
5638 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
5640 tracing::info!("start_action: executed {} pending jobs", count);
5641 });
5642
5643 tracing::info!("start_action: END '{}'", action_name);
5644
5645 self.services.clear_js_execution_state();
5647
5648 Ok(())
5649 }
5650
5651 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
5653 let pair = self.registered_actions.borrow().get(action_name).cloned();
5655 let (plugin_name, function_name) = match pair {
5656 Some(handler) => (handler.plugin_name, handler.handler_name),
5657 None => ("main".to_string(), action_name.to_string()),
5658 };
5659
5660 let plugin_contexts = self.plugin_contexts.borrow();
5661 let context = plugin_contexts
5662 .get(&plugin_name)
5663 .unwrap_or(&self.main_context);
5664
5665 tracing::debug!(
5666 "execute_action: '{}' -> function '{}'",
5667 action_name,
5668 function_name
5669 );
5670
5671 let code = format!(
5674 r#"
5675 (async function() {{
5676 try {{
5677 if (typeof globalThis.{fn} === 'function') {{
5678 const result = globalThis.{fn}();
5679 // If it's a Promise, await it
5680 if (result && typeof result.then === 'function') {{
5681 await result;
5682 }}
5683 }} else {{
5684 console.error('Action {action} is not defined as a global function');
5685 }}
5686 }} catch (e) {{
5687 console.error('Action {action} error:', e);
5688 }}
5689 }})();
5690 "#,
5691 fn = function_name,
5692 action = action_name
5693 );
5694
5695 context.with(|ctx| {
5696 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5698 Ok(value) => {
5699 if value.is_object() {
5701 if let Some(obj) = value.as_object() {
5702 if obj.get::<_, rquickjs::Function>("then").is_ok() {
5704 run_pending_jobs_checked(
5707 &ctx,
5708 &format!("execute_action {} promise", action_name),
5709 );
5710 }
5711 }
5712 }
5713 }
5714 Err(e) => {
5715 log_js_error(&ctx, e, &format!("action {}", action_name));
5716 }
5717 }
5718 });
5719
5720 Ok(())
5721 }
5722
5723 pub fn poll_event_loop_once(&mut self) -> bool {
5725 let mut had_work = false;
5726
5727 self.main_context.with(|ctx| {
5729 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
5730 if count > 0 {
5731 had_work = true;
5732 }
5733 });
5734
5735 let contexts = self.plugin_contexts.borrow().clone();
5737 for (name, context) in contexts {
5738 context.with(|ctx| {
5739 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
5740 if count > 0 {
5741 had_work = true;
5742 }
5743 });
5744 }
5745 had_work
5746 }
5747
5748 pub fn send_status(&self, message: String) {
5750 let _ = self
5751 .command_sender
5752 .send(PluginCommand::SetStatus { message });
5753 }
5754
5755 pub fn send_hook_completed(&self, hook_name: String) {
5759 let _ = self
5760 .command_sender
5761 .send(PluginCommand::HookCompleted { hook_name });
5762 }
5763
5764 pub fn resolve_callback(
5769 &mut self,
5770 callback_id: fresh_core::api::JsCallbackId,
5771 result_json: &str,
5772 ) {
5773 let id = callback_id.as_u64();
5774 tracing::debug!("resolve_callback: starting for callback_id={}", id);
5775
5776 let plugin_name = {
5778 let mut contexts = self.callback_contexts.borrow_mut();
5779 contexts.remove(&id)
5780 };
5781
5782 let Some(name) = plugin_name else {
5783 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5784 return;
5785 };
5786
5787 let plugin_contexts = self.plugin_contexts.borrow();
5788 let Some(context) = plugin_contexts.get(&name) else {
5789 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5790 return;
5791 };
5792
5793 context.with(|ctx| {
5794 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5796 Ok(v) => v,
5797 Err(e) => {
5798 tracing::error!(
5799 "resolve_callback: failed to parse JSON for callback_id={}: {}",
5800 id,
5801 e
5802 );
5803 return;
5804 }
5805 };
5806
5807 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5809 Ok(v) => v,
5810 Err(e) => {
5811 tracing::error!(
5812 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5813 id,
5814 e
5815 );
5816 return;
5817 }
5818 };
5819
5820 let globals = ctx.globals();
5822 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5823 Ok(f) => f,
5824 Err(e) => {
5825 tracing::error!(
5826 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5827 id,
5828 e
5829 );
5830 return;
5831 }
5832 };
5833
5834 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5836 log_js_error(&ctx, e, &format!("resolving callback {}", id));
5837 }
5838
5839 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5841 tracing::info!(
5842 "resolve_callback: executed {} pending jobs for callback_id={}",
5843 job_count,
5844 id
5845 );
5846 });
5847 }
5848
5849 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5851 let id = callback_id.as_u64();
5852
5853 let plugin_name = {
5855 let mut contexts = self.callback_contexts.borrow_mut();
5856 contexts.remove(&id)
5857 };
5858
5859 let Some(name) = plugin_name else {
5860 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5861 return;
5862 };
5863
5864 let plugin_contexts = self.plugin_contexts.borrow();
5865 let Some(context) = plugin_contexts.get(&name) else {
5866 tracing::warn!("reject_callback: Context lost for plugin {}", name);
5867 return;
5868 };
5869
5870 context.with(|ctx| {
5871 let globals = ctx.globals();
5873 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5874 Ok(f) => f,
5875 Err(e) => {
5876 tracing::error!(
5877 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5878 id,
5879 e
5880 );
5881 return;
5882 }
5883 };
5884
5885 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5887 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5888 }
5889
5890 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5892 });
5893 }
5894
5895 pub fn call_streaming_callback(
5899 &mut self,
5900 callback_id: fresh_core::api::JsCallbackId,
5901 result_json: &str,
5902 done: bool,
5903 ) {
5904 let id = callback_id.as_u64();
5905
5906 let plugin_name = {
5908 let contexts = self.callback_contexts.borrow();
5909 contexts.get(&id).cloned()
5910 };
5911
5912 let Some(name) = plugin_name else {
5913 tracing::warn!(
5914 "call_streaming_callback: No plugin found for callback_id={}",
5915 id
5916 );
5917 return;
5918 };
5919
5920 if done {
5922 self.callback_contexts.borrow_mut().remove(&id);
5923 }
5924
5925 let plugin_contexts = self.plugin_contexts.borrow();
5926 let Some(context) = plugin_contexts.get(&name) else {
5927 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5928 return;
5929 };
5930
5931 context.with(|ctx| {
5932 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5933 Ok(v) => v,
5934 Err(e) => {
5935 tracing::error!(
5936 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5937 id,
5938 e
5939 );
5940 return;
5941 }
5942 };
5943
5944 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5945 Ok(v) => v,
5946 Err(e) => {
5947 tracing::error!(
5948 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5949 id,
5950 e
5951 );
5952 return;
5953 }
5954 };
5955
5956 let globals = ctx.globals();
5957 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5958 Ok(f) => f,
5959 Err(e) => {
5960 tracing::error!(
5961 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5962 id,
5963 e
5964 );
5965 return;
5966 }
5967 };
5968
5969 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5970 log_js_error(
5971 &ctx,
5972 e,
5973 &format!("calling streaming callback {}", id),
5974 );
5975 }
5976
5977 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5978 });
5979 }
5980}
5981
5982#[cfg(test)]
5983mod tests {
5984 use super::*;
5985 use fresh_core::api::{BufferInfo, CursorInfo};
5986 use std::sync::mpsc;
5987
5988 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5990 let (tx, rx) = mpsc::channel();
5991 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5992 let services = Arc::new(TestServiceBridge::new());
5993 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5994 (backend, rx)
5995 }
5996
5997 struct TestServiceBridge {
5998 en_strings: std::sync::Mutex<HashMap<String, String>>,
5999 }
6000
6001 impl TestServiceBridge {
6002 fn new() -> Self {
6003 Self {
6004 en_strings: std::sync::Mutex::new(HashMap::new()),
6005 }
6006 }
6007 }
6008
6009 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
6010 fn as_any(&self) -> &dyn std::any::Any {
6011 self
6012 }
6013 fn translate(
6014 &self,
6015 _plugin_name: &str,
6016 key: &str,
6017 _args: &HashMap<String, String>,
6018 ) -> String {
6019 self.en_strings
6020 .lock()
6021 .unwrap()
6022 .get(key)
6023 .cloned()
6024 .unwrap_or_else(|| key.to_string())
6025 }
6026 fn current_locale(&self) -> String {
6027 "en".to_string()
6028 }
6029 fn set_js_execution_state(&self, _state: String) {}
6030 fn clear_js_execution_state(&self) {}
6031 fn get_theme_schema(&self) -> serde_json::Value {
6032 serde_json::json!({})
6033 }
6034 fn get_builtin_themes(&self) -> serde_json::Value {
6035 serde_json::json!([])
6036 }
6037 fn get_all_themes(&self) -> serde_json::Value {
6038 serde_json::json!({})
6039 }
6040 fn register_command(&self, _command: fresh_core::command::Command) {}
6041 fn unregister_command(&self, _name: &str) {}
6042 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
6043 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
6044 fn plugins_dir(&self) -> std::path::PathBuf {
6045 std::path::PathBuf::from("/tmp/plugins")
6046 }
6047 fn config_dir(&self) -> std::path::PathBuf {
6048 std::path::PathBuf::from("/tmp/config")
6049 }
6050 fn data_dir(&self) -> std::path::PathBuf {
6051 std::path::PathBuf::from("/tmp/data")
6052 }
6053 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
6054 None
6055 }
6056 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6057 Err("not implemented in test".to_string())
6058 }
6059 fn theme_file_exists(&self, _name: &str) -> bool {
6060 false
6061 }
6062 }
6063
6064 #[test]
6065 fn test_quickjs_backend_creation() {
6066 let backend = QuickJsBackend::new();
6067 assert!(backend.is_ok());
6068 }
6069
6070 #[test]
6071 fn test_execute_simple_js() {
6072 let mut backend = QuickJsBackend::new().unwrap();
6073 let result = backend.execute_js("const x = 1 + 2;", "test.js");
6074 assert!(result.is_ok());
6075 }
6076
6077 #[test]
6078 fn test_event_handler_registration() {
6079 let backend = QuickJsBackend::new().unwrap();
6080
6081 assert!(!backend.has_handlers("test_event"));
6083
6084 backend
6086 .event_handlers
6087 .borrow_mut()
6088 .entry("test_event".to_string())
6089 .or_default()
6090 .push(PluginHandler {
6091 plugin_name: "test".to_string(),
6092 handler_name: "testHandler".to_string(),
6093 });
6094
6095 assert!(backend.has_handlers("test_event"));
6097 }
6098
6099 #[test]
6102 fn test_api_set_status() {
6103 let (mut backend, rx) = create_test_backend();
6104
6105 backend
6106 .execute_js(
6107 r#"
6108 const editor = getEditor();
6109 editor.setStatus("Hello from test");
6110 "#,
6111 "test.js",
6112 )
6113 .unwrap();
6114
6115 let cmd = rx.try_recv().unwrap();
6116 match cmd {
6117 PluginCommand::SetStatus { message } => {
6118 assert_eq!(message, "Hello from test");
6119 }
6120 _ => panic!("Expected SetStatus command, got {:?}", cmd),
6121 }
6122 }
6123
6124 #[test]
6125 fn test_api_register_command() {
6126 let (mut backend, rx) = create_test_backend();
6127
6128 backend
6129 .execute_js(
6130 r#"
6131 const editor = getEditor();
6132 globalThis.myTestHandler = function() { };
6133 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
6134 "#,
6135 "test_plugin.js",
6136 )
6137 .unwrap();
6138
6139 let cmd = rx.try_recv().unwrap();
6140 match cmd {
6141 PluginCommand::RegisterCommand { command } => {
6142 assert_eq!(command.name, "Test Command");
6143 assert_eq!(command.description, "A test command");
6144 assert_eq!(command.plugin_name, "test_plugin");
6146 }
6147 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
6148 }
6149 }
6150
6151 #[test]
6152 fn test_api_define_mode() {
6153 let (mut backend, rx) = create_test_backend();
6154
6155 backend
6156 .execute_js(
6157 r#"
6158 const editor = getEditor();
6159 editor.defineMode("test-mode", [
6160 ["a", "action_a"],
6161 ["b", "action_b"]
6162 ]);
6163 "#,
6164 "test.js",
6165 )
6166 .unwrap();
6167
6168 let cmd = rx.try_recv().unwrap();
6169 match cmd {
6170 PluginCommand::DefineMode {
6171 name,
6172 bindings,
6173 read_only,
6174 allow_text_input,
6175 inherit_normal_bindings,
6176 plugin_name,
6177 } => {
6178 assert_eq!(name, "test-mode");
6179 assert_eq!(bindings.len(), 2);
6180 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
6181 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
6182 assert!(!read_only);
6183 assert!(!allow_text_input);
6184 assert!(!inherit_normal_bindings);
6185 assert!(plugin_name.is_some());
6186 }
6187 _ => panic!("Expected DefineMode, got {:?}", cmd),
6188 }
6189 }
6190
6191 #[test]
6192 fn test_api_set_editor_mode() {
6193 let (mut backend, rx) = create_test_backend();
6194
6195 backend
6196 .execute_js(
6197 r#"
6198 const editor = getEditor();
6199 editor.setEditorMode("vi-normal");
6200 "#,
6201 "test.js",
6202 )
6203 .unwrap();
6204
6205 let cmd = rx.try_recv().unwrap();
6206 match cmd {
6207 PluginCommand::SetEditorMode { mode } => {
6208 assert_eq!(mode, Some("vi-normal".to_string()));
6209 }
6210 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
6211 }
6212 }
6213
6214 #[test]
6215 fn test_api_clear_editor_mode() {
6216 let (mut backend, rx) = create_test_backend();
6217
6218 backend
6219 .execute_js(
6220 r#"
6221 const editor = getEditor();
6222 editor.setEditorMode(null);
6223 "#,
6224 "test.js",
6225 )
6226 .unwrap();
6227
6228 let cmd = rx.try_recv().unwrap();
6229 match cmd {
6230 PluginCommand::SetEditorMode { mode } => {
6231 assert!(mode.is_none());
6232 }
6233 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
6234 }
6235 }
6236
6237 #[test]
6238 fn test_api_insert_at_cursor() {
6239 let (mut backend, rx) = create_test_backend();
6240
6241 backend
6242 .execute_js(
6243 r#"
6244 const editor = getEditor();
6245 editor.insertAtCursor("Hello, World!");
6246 "#,
6247 "test.js",
6248 )
6249 .unwrap();
6250
6251 let cmd = rx.try_recv().unwrap();
6252 match cmd {
6253 PluginCommand::InsertAtCursor { text } => {
6254 assert_eq!(text, "Hello, World!");
6255 }
6256 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
6257 }
6258 }
6259
6260 #[test]
6261 fn test_api_set_context() {
6262 let (mut backend, rx) = create_test_backend();
6263
6264 backend
6265 .execute_js(
6266 r#"
6267 const editor = getEditor();
6268 editor.setContext("myContext", true);
6269 "#,
6270 "test.js",
6271 )
6272 .unwrap();
6273
6274 let cmd = rx.try_recv().unwrap();
6275 match cmd {
6276 PluginCommand::SetContext { name, active } => {
6277 assert_eq!(name, "myContext");
6278 assert!(active);
6279 }
6280 _ => panic!("Expected SetContext, got {:?}", cmd),
6281 }
6282 }
6283
6284 #[tokio::test]
6285 async fn test_execute_action_sync_function() {
6286 let (mut backend, rx) = create_test_backend();
6287
6288 backend.registered_actions.borrow_mut().insert(
6290 "my_sync_action".to_string(),
6291 PluginHandler {
6292 plugin_name: "test".to_string(),
6293 handler_name: "my_sync_action".to_string(),
6294 },
6295 );
6296
6297 backend
6299 .execute_js(
6300 r#"
6301 const editor = getEditor();
6302 globalThis.my_sync_action = function() {
6303 editor.setStatus("sync action executed");
6304 };
6305 "#,
6306 "test.js",
6307 )
6308 .unwrap();
6309
6310 while rx.try_recv().is_ok() {}
6312
6313 backend.execute_action("my_sync_action").await.unwrap();
6315
6316 let cmd = rx.try_recv().unwrap();
6318 match cmd {
6319 PluginCommand::SetStatus { message } => {
6320 assert_eq!(message, "sync action executed");
6321 }
6322 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
6323 }
6324 }
6325
6326 #[tokio::test]
6327 async fn test_execute_action_async_function() {
6328 let (mut backend, rx) = create_test_backend();
6329
6330 backend.registered_actions.borrow_mut().insert(
6332 "my_async_action".to_string(),
6333 PluginHandler {
6334 plugin_name: "test".to_string(),
6335 handler_name: "my_async_action".to_string(),
6336 },
6337 );
6338
6339 backend
6341 .execute_js(
6342 r#"
6343 const editor = getEditor();
6344 globalThis.my_async_action = async function() {
6345 await Promise.resolve();
6346 editor.setStatus("async action executed");
6347 };
6348 "#,
6349 "test.js",
6350 )
6351 .unwrap();
6352
6353 while rx.try_recv().is_ok() {}
6355
6356 backend.execute_action("my_async_action").await.unwrap();
6358
6359 let cmd = rx.try_recv().unwrap();
6361 match cmd {
6362 PluginCommand::SetStatus { message } => {
6363 assert_eq!(message, "async action executed");
6364 }
6365 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
6366 }
6367 }
6368
6369 #[tokio::test]
6370 async fn test_execute_action_with_registered_handler() {
6371 let (mut backend, rx) = create_test_backend();
6372
6373 backend.registered_actions.borrow_mut().insert(
6375 "my_action".to_string(),
6376 PluginHandler {
6377 plugin_name: "test".to_string(),
6378 handler_name: "actual_handler_function".to_string(),
6379 },
6380 );
6381
6382 backend
6383 .execute_js(
6384 r#"
6385 const editor = getEditor();
6386 globalThis.actual_handler_function = function() {
6387 editor.setStatus("handler executed");
6388 };
6389 "#,
6390 "test.js",
6391 )
6392 .unwrap();
6393
6394 while rx.try_recv().is_ok() {}
6396
6397 backend.execute_action("my_action").await.unwrap();
6399
6400 let cmd = rx.try_recv().unwrap();
6401 match cmd {
6402 PluginCommand::SetStatus { message } => {
6403 assert_eq!(message, "handler executed");
6404 }
6405 _ => panic!("Expected SetStatus, got {:?}", cmd),
6406 }
6407 }
6408
6409 #[test]
6410 fn test_api_on_event_registration() {
6411 let (mut backend, _rx) = create_test_backend();
6412
6413 backend
6414 .execute_js(
6415 r#"
6416 const editor = getEditor();
6417 globalThis.myEventHandler = function() { };
6418 editor.on("bufferSave", "myEventHandler");
6419 "#,
6420 "test.js",
6421 )
6422 .unwrap();
6423
6424 assert!(backend.has_handlers("bufferSave"));
6425 }
6426
6427 #[test]
6428 fn test_api_off_event_unregistration() {
6429 let (mut backend, _rx) = create_test_backend();
6430
6431 backend
6432 .execute_js(
6433 r#"
6434 const editor = getEditor();
6435 globalThis.myEventHandler = function() { };
6436 editor.on("bufferSave", "myEventHandler");
6437 editor.off("bufferSave", "myEventHandler");
6438 "#,
6439 "test.js",
6440 )
6441 .unwrap();
6442
6443 assert!(!backend.has_handlers("bufferSave"));
6445 }
6446
6447 #[tokio::test]
6448 async fn test_emit_event() {
6449 let (mut backend, rx) = create_test_backend();
6450
6451 backend
6452 .execute_js(
6453 r#"
6454 const editor = getEditor();
6455 globalThis.onSaveHandler = function(data) {
6456 editor.setStatus("saved: " + JSON.stringify(data));
6457 };
6458 editor.on("bufferSave", "onSaveHandler");
6459 "#,
6460 "test.js",
6461 )
6462 .unwrap();
6463
6464 while rx.try_recv().is_ok() {}
6466
6467 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
6469 backend.emit("bufferSave", &event_data).await.unwrap();
6470
6471 let cmd = rx.try_recv().unwrap();
6472 match cmd {
6473 PluginCommand::SetStatus { message } => {
6474 assert!(message.contains("/test.txt"));
6475 }
6476 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
6477 }
6478 }
6479
6480 #[test]
6481 fn test_api_copy_to_clipboard() {
6482 let (mut backend, rx) = create_test_backend();
6483
6484 backend
6485 .execute_js(
6486 r#"
6487 const editor = getEditor();
6488 editor.copyToClipboard("clipboard text");
6489 "#,
6490 "test.js",
6491 )
6492 .unwrap();
6493
6494 let cmd = rx.try_recv().unwrap();
6495 match cmd {
6496 PluginCommand::SetClipboard { text } => {
6497 assert_eq!(text, "clipboard text");
6498 }
6499 _ => panic!("Expected SetClipboard, got {:?}", cmd),
6500 }
6501 }
6502
6503 #[test]
6504 fn test_api_open_file() {
6505 let (mut backend, rx) = create_test_backend();
6506
6507 backend
6509 .execute_js(
6510 r#"
6511 const editor = getEditor();
6512 editor.openFile("/path/to/file.txt", null, null);
6513 "#,
6514 "test.js",
6515 )
6516 .unwrap();
6517
6518 let cmd = rx.try_recv().unwrap();
6519 match cmd {
6520 PluginCommand::OpenFileAtLocation { path, line, column } => {
6521 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
6522 assert!(line.is_none());
6523 assert!(column.is_none());
6524 }
6525 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
6526 }
6527 }
6528
6529 #[test]
6530 fn test_api_delete_range() {
6531 let (mut backend, rx) = create_test_backend();
6532
6533 backend
6535 .execute_js(
6536 r#"
6537 const editor = getEditor();
6538 editor.deleteRange(0, 10, 20);
6539 "#,
6540 "test.js",
6541 )
6542 .unwrap();
6543
6544 let cmd = rx.try_recv().unwrap();
6545 match cmd {
6546 PluginCommand::DeleteRange { range, .. } => {
6547 assert_eq!(range.start, 10);
6548 assert_eq!(range.end, 20);
6549 }
6550 _ => panic!("Expected DeleteRange, got {:?}", cmd),
6551 }
6552 }
6553
6554 #[test]
6555 fn test_api_insert_text() {
6556 let (mut backend, rx) = create_test_backend();
6557
6558 backend
6560 .execute_js(
6561 r#"
6562 const editor = getEditor();
6563 editor.insertText(0, 5, "inserted");
6564 "#,
6565 "test.js",
6566 )
6567 .unwrap();
6568
6569 let cmd = rx.try_recv().unwrap();
6570 match cmd {
6571 PluginCommand::InsertText { position, text, .. } => {
6572 assert_eq!(position, 5);
6573 assert_eq!(text, "inserted");
6574 }
6575 _ => panic!("Expected InsertText, got {:?}", cmd),
6576 }
6577 }
6578
6579 #[test]
6580 fn test_api_set_buffer_cursor() {
6581 let (mut backend, rx) = create_test_backend();
6582
6583 backend
6585 .execute_js(
6586 r#"
6587 const editor = getEditor();
6588 editor.setBufferCursor(0, 100);
6589 "#,
6590 "test.js",
6591 )
6592 .unwrap();
6593
6594 let cmd = rx.try_recv().unwrap();
6595 match cmd {
6596 PluginCommand::SetBufferCursor { position, .. } => {
6597 assert_eq!(position, 100);
6598 }
6599 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
6600 }
6601 }
6602
6603 #[test]
6604 fn test_api_get_cursor_position_from_state() {
6605 let (tx, _rx) = mpsc::channel();
6606 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6607
6608 {
6610 let mut state = state_snapshot.write().unwrap();
6611 state.primary_cursor = Some(CursorInfo {
6612 position: 42,
6613 selection: None,
6614 });
6615 }
6616
6617 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6618 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6619
6620 backend
6622 .execute_js(
6623 r#"
6624 const editor = getEditor();
6625 const pos = editor.getCursorPosition();
6626 globalThis._testResult = pos;
6627 "#,
6628 "test.js",
6629 )
6630 .unwrap();
6631
6632 backend
6634 .plugin_contexts
6635 .borrow()
6636 .get("test")
6637 .unwrap()
6638 .clone()
6639 .with(|ctx| {
6640 let global = ctx.globals();
6641 let result: u32 = global.get("_testResult").unwrap();
6642 assert_eq!(result, 42);
6643 });
6644 }
6645
6646 #[test]
6647 fn test_api_path_functions() {
6648 let (mut backend, _rx) = create_test_backend();
6649
6650 #[cfg(windows)]
6653 let absolute_path = r#"C:\\foo\\bar"#;
6654 #[cfg(not(windows))]
6655 let absolute_path = "/foo/bar";
6656
6657 let js_code = format!(
6659 r#"
6660 const editor = getEditor();
6661 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
6662 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
6663 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
6664 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
6665 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
6666 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
6667 "#,
6668 absolute_path
6669 );
6670 backend.execute_js(&js_code, "test.js").unwrap();
6671
6672 backend
6673 .plugin_contexts
6674 .borrow()
6675 .get("test")
6676 .unwrap()
6677 .clone()
6678 .with(|ctx| {
6679 let global = ctx.globals();
6680 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
6681 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
6682 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
6683 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
6684 assert!(!global.get::<_, bool>("_isRelative").unwrap());
6685 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
6686 });
6687 }
6688
6689 #[test]
6697 fn test_path_join_preserves_unc_prefix() {
6698 let (mut backend, _rx) = create_test_backend();
6699 backend
6700 .execute_js(
6701 r#"
6702 const editor = getEditor();
6703 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
6704 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
6705 globalThis._posix = editor.pathJoin("/foo", "bar");
6706 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
6707 "#,
6708 "test.js",
6709 )
6710 .unwrap();
6711
6712 backend
6713 .plugin_contexts
6714 .borrow()
6715 .get("test")
6716 .unwrap()
6717 .clone()
6718 .with(|ctx| {
6719 let global = ctx.globals();
6720 assert_eq!(
6721 global.get::<_, String>("_unc").unwrap(),
6722 "//?/C:/workspace/.devcontainer/devcontainer.json",
6723 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
6724 );
6725 assert_eq!(
6726 global.get::<_, String>("_unc_fwd").unwrap(),
6727 "//?/C:/workspace/.devcontainer/devcontainer.json",
6728 "UNC prefix in forward-slash form stays as `//`",
6729 );
6730 assert_eq!(
6731 global.get::<_, String>("_posix").unwrap(),
6732 "/foo/bar",
6733 "POSIX absolute paths keep their single leading slash",
6734 );
6735 assert_eq!(
6736 global.get::<_, String>("_drive").unwrap(),
6737 "C:/foo/bar",
6738 "Windows drive-letter paths have no leading slash",
6739 );
6740 });
6741 }
6742
6743 #[test]
6744 fn test_file_uri_to_path_and_back() {
6745 let (mut backend, _rx) = create_test_backend();
6746
6747 #[cfg(not(windows))]
6749 let js_code = r#"
6750 const editor = getEditor();
6751 // Basic file URI to path
6752 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
6753 // Percent-encoded characters
6754 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
6755 // Invalid URI returns empty string
6756 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6757 // Path to file URI
6758 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
6759 // Round-trip
6760 globalThis._roundtrip = editor.fileUriToPath(
6761 editor.pathToFileUri("/home/user/file.txt")
6762 );
6763 "#;
6764
6765 #[cfg(windows)]
6766 let js_code = r#"
6767 const editor = getEditor();
6768 // Windows URI with encoded colon (the bug from issue #1071)
6769 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
6770 // Windows URI with normal colon
6771 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
6772 // Invalid URI returns empty string
6773 globalThis._path3 = editor.fileUriToPath("not-a-uri");
6774 // Path to file URI
6775 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
6776 // Round-trip
6777 globalThis._roundtrip = editor.fileUriToPath(
6778 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
6779 );
6780 "#;
6781
6782 backend.execute_js(js_code, "test.js").unwrap();
6783
6784 backend
6785 .plugin_contexts
6786 .borrow()
6787 .get("test")
6788 .unwrap()
6789 .clone()
6790 .with(|ctx| {
6791 let global = ctx.globals();
6792
6793 #[cfg(not(windows))]
6794 {
6795 assert_eq!(
6796 global.get::<_, String>("_path1").unwrap(),
6797 "/home/user/file.txt"
6798 );
6799 assert_eq!(
6800 global.get::<_, String>("_path2").unwrap(),
6801 "/home/user/my file.txt"
6802 );
6803 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6804 assert_eq!(
6805 global.get::<_, String>("_uri1").unwrap(),
6806 "file:///home/user/file.txt"
6807 );
6808 assert_eq!(
6809 global.get::<_, String>("_roundtrip").unwrap(),
6810 "/home/user/file.txt"
6811 );
6812 }
6813
6814 #[cfg(windows)]
6815 {
6816 assert_eq!(
6818 global.get::<_, String>("_path1").unwrap(),
6819 "C:\\Users\\admin\\Repos\\file.cs"
6820 );
6821 assert_eq!(
6822 global.get::<_, String>("_path2").unwrap(),
6823 "C:\\Users\\admin\\Repos\\file.cs"
6824 );
6825 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6826 assert_eq!(
6827 global.get::<_, String>("_uri1").unwrap(),
6828 "file:///C:/Users/admin/Repos/file.cs"
6829 );
6830 assert_eq!(
6831 global.get::<_, String>("_roundtrip").unwrap(),
6832 "C:\\Users\\admin\\Repos\\file.cs"
6833 );
6834 }
6835 });
6836 }
6837
6838 #[test]
6839 fn test_typescript_transpilation() {
6840 use fresh_parser_js::transpile_typescript;
6841
6842 let (mut backend, rx) = create_test_backend();
6843
6844 let ts_code = r#"
6846 const editor = getEditor();
6847 function greet(name: string): string {
6848 return "Hello, " + name;
6849 }
6850 editor.setStatus(greet("TypeScript"));
6851 "#;
6852
6853 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6855
6856 backend.execute_js(&js_code, "test.js").unwrap();
6858
6859 let cmd = rx.try_recv().unwrap();
6860 match cmd {
6861 PluginCommand::SetStatus { message } => {
6862 assert_eq!(message, "Hello, TypeScript");
6863 }
6864 _ => panic!("Expected SetStatus, got {:?}", cmd),
6865 }
6866 }
6867
6868 #[test]
6869 fn test_api_get_buffer_text_sends_command() {
6870 let (mut backend, rx) = create_test_backend();
6871
6872 backend
6874 .execute_js(
6875 r#"
6876 const editor = getEditor();
6877 // Store the promise for later
6878 globalThis._textPromise = editor.getBufferText(0, 10, 20);
6879 "#,
6880 "test.js",
6881 )
6882 .unwrap();
6883
6884 let cmd = rx.try_recv().unwrap();
6886 match cmd {
6887 PluginCommand::GetBufferText {
6888 buffer_id,
6889 start,
6890 end,
6891 request_id,
6892 } => {
6893 assert_eq!(buffer_id.0, 0);
6894 assert_eq!(start, 10);
6895 assert_eq!(end, 20);
6896 assert!(request_id > 0); }
6898 _ => panic!("Expected GetBufferText, got {:?}", cmd),
6899 }
6900 }
6901
6902 #[test]
6903 fn test_api_get_buffer_text_resolves_callback() {
6904 let (mut backend, rx) = create_test_backend();
6905
6906 backend
6908 .execute_js(
6909 r#"
6910 const editor = getEditor();
6911 globalThis._resolvedText = null;
6912 editor.getBufferText(0, 0, 100).then(text => {
6913 globalThis._resolvedText = text;
6914 });
6915 "#,
6916 "test.js",
6917 )
6918 .unwrap();
6919
6920 let request_id = match rx.try_recv().unwrap() {
6922 PluginCommand::GetBufferText { request_id, .. } => request_id,
6923 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6924 };
6925
6926 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6928
6929 backend
6931 .plugin_contexts
6932 .borrow()
6933 .get("test")
6934 .unwrap()
6935 .clone()
6936 .with(|ctx| {
6937 run_pending_jobs_checked(&ctx, "test async getText");
6938 });
6939
6940 backend
6942 .plugin_contexts
6943 .borrow()
6944 .get("test")
6945 .unwrap()
6946 .clone()
6947 .with(|ctx| {
6948 let global = ctx.globals();
6949 let result: String = global.get("_resolvedText").unwrap();
6950 assert_eq!(result, "hello world");
6951 });
6952 }
6953
6954 #[test]
6955 fn test_plugin_translation() {
6956 let (mut backend, _rx) = create_test_backend();
6957
6958 backend
6960 .execute_js(
6961 r#"
6962 const editor = getEditor();
6963 globalThis._translated = editor.t("test.key");
6964 "#,
6965 "test.js",
6966 )
6967 .unwrap();
6968
6969 backend
6970 .plugin_contexts
6971 .borrow()
6972 .get("test")
6973 .unwrap()
6974 .clone()
6975 .with(|ctx| {
6976 let global = ctx.globals();
6977 let result: String = global.get("_translated").unwrap();
6979 assert_eq!(result, "test.key");
6980 });
6981 }
6982
6983 #[test]
6984 fn test_plugin_translation_with_registered_strings() {
6985 let (mut backend, _rx) = create_test_backend();
6986
6987 let mut en_strings = std::collections::HashMap::new();
6989 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6990 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6991
6992 let mut strings = std::collections::HashMap::new();
6993 strings.insert("en".to_string(), en_strings);
6994
6995 if let Some(bridge) = backend
6997 .services
6998 .as_any()
6999 .downcast_ref::<TestServiceBridge>()
7000 {
7001 let mut en = bridge.en_strings.lock().unwrap();
7002 en.insert("greeting".to_string(), "Hello, World!".to_string());
7003 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
7004 }
7005
7006 backend
7008 .execute_js(
7009 r#"
7010 const editor = getEditor();
7011 globalThis._greeting = editor.t("greeting");
7012 globalThis._prompt = editor.t("prompt.find_file");
7013 globalThis._missing = editor.t("nonexistent.key");
7014 "#,
7015 "test.js",
7016 )
7017 .unwrap();
7018
7019 backend
7020 .plugin_contexts
7021 .borrow()
7022 .get("test")
7023 .unwrap()
7024 .clone()
7025 .with(|ctx| {
7026 let global = ctx.globals();
7027 let greeting: String = global.get("_greeting").unwrap();
7028 assert_eq!(greeting, "Hello, World!");
7029
7030 let prompt: String = global.get("_prompt").unwrap();
7031 assert_eq!(prompt, "Find file: ");
7032
7033 let missing: String = global.get("_missing").unwrap();
7035 assert_eq!(missing, "nonexistent.key");
7036 });
7037 }
7038
7039 #[test]
7042 fn test_api_set_line_indicator() {
7043 let (mut backend, rx) = create_test_backend();
7044
7045 backend
7046 .execute_js(
7047 r#"
7048 const editor = getEditor();
7049 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
7050 "#,
7051 "test.js",
7052 )
7053 .unwrap();
7054
7055 let cmd = rx.try_recv().unwrap();
7056 match cmd {
7057 PluginCommand::SetLineIndicator {
7058 buffer_id,
7059 line,
7060 namespace,
7061 symbol,
7062 color,
7063 priority,
7064 } => {
7065 assert_eq!(buffer_id.0, 1);
7066 assert_eq!(line, 5);
7067 assert_eq!(namespace, "test-ns");
7068 assert_eq!(symbol, "●");
7069 assert_eq!(color, (255, 0, 0));
7070 assert_eq!(priority, 10);
7071 }
7072 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
7073 }
7074 }
7075
7076 #[test]
7077 fn test_api_clear_line_indicators() {
7078 let (mut backend, rx) = create_test_backend();
7079
7080 backend
7081 .execute_js(
7082 r#"
7083 const editor = getEditor();
7084 editor.clearLineIndicators(1, "test-ns");
7085 "#,
7086 "test.js",
7087 )
7088 .unwrap();
7089
7090 let cmd = rx.try_recv().unwrap();
7091 match cmd {
7092 PluginCommand::ClearLineIndicators {
7093 buffer_id,
7094 namespace,
7095 } => {
7096 assert_eq!(buffer_id.0, 1);
7097 assert_eq!(namespace, "test-ns");
7098 }
7099 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
7100 }
7101 }
7102
7103 #[test]
7106 fn test_api_create_virtual_buffer_sends_command() {
7107 let (mut backend, rx) = create_test_backend();
7108
7109 backend
7110 .execute_js(
7111 r#"
7112 const editor = getEditor();
7113 editor.createVirtualBuffer({
7114 name: "*Test Buffer*",
7115 mode: "test-mode",
7116 readOnly: true,
7117 entries: [
7118 { text: "Line 1\n", properties: { type: "header" } },
7119 { text: "Line 2\n", properties: { type: "content" } }
7120 ],
7121 showLineNumbers: false,
7122 showCursors: true,
7123 editingDisabled: true
7124 });
7125 "#,
7126 "test.js",
7127 )
7128 .unwrap();
7129
7130 let cmd = rx.try_recv().unwrap();
7131 match cmd {
7132 PluginCommand::CreateVirtualBufferWithContent {
7133 name,
7134 mode,
7135 read_only,
7136 entries,
7137 show_line_numbers,
7138 show_cursors,
7139 editing_disabled,
7140 ..
7141 } => {
7142 assert_eq!(name, "*Test Buffer*");
7143 assert_eq!(mode, "test-mode");
7144 assert!(read_only);
7145 assert_eq!(entries.len(), 2);
7146 assert_eq!(entries[0].text, "Line 1\n");
7147 assert!(!show_line_numbers);
7148 assert!(show_cursors);
7149 assert!(editing_disabled);
7150 }
7151 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
7152 }
7153 }
7154
7155 #[test]
7156 fn test_api_set_virtual_buffer_content() {
7157 let (mut backend, rx) = create_test_backend();
7158
7159 backend
7160 .execute_js(
7161 r#"
7162 const editor = getEditor();
7163 editor.setVirtualBufferContent(5, [
7164 { text: "New content\n", properties: { type: "updated" } }
7165 ]);
7166 "#,
7167 "test.js",
7168 )
7169 .unwrap();
7170
7171 let cmd = rx.try_recv().unwrap();
7172 match cmd {
7173 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
7174 assert_eq!(buffer_id.0, 5);
7175 assert_eq!(entries.len(), 1);
7176 assert_eq!(entries[0].text, "New content\n");
7177 }
7178 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
7179 }
7180 }
7181
7182 #[test]
7185 fn test_api_add_overlay() {
7186 let (mut backend, rx) = create_test_backend();
7187
7188 backend
7189 .execute_js(
7190 r#"
7191 const editor = getEditor();
7192 editor.addOverlay(1, "highlight", 10, 20, {
7193 fg: [255, 128, 0],
7194 bg: [50, 50, 50],
7195 bold: true,
7196 });
7197 "#,
7198 "test.js",
7199 )
7200 .unwrap();
7201
7202 let cmd = rx.try_recv().unwrap();
7203 match cmd {
7204 PluginCommand::AddOverlay {
7205 buffer_id,
7206 namespace,
7207 range,
7208 options,
7209 } => {
7210 use fresh_core::api::OverlayColorSpec;
7211 assert_eq!(buffer_id.0, 1);
7212 assert!(namespace.is_some());
7213 assert_eq!(namespace.unwrap().as_str(), "highlight");
7214 assert_eq!(range, 10..20);
7215 assert!(matches!(
7216 options.fg,
7217 Some(OverlayColorSpec::Rgb(255, 128, 0))
7218 ));
7219 assert!(matches!(
7220 options.bg,
7221 Some(OverlayColorSpec::Rgb(50, 50, 50))
7222 ));
7223 assert!(!options.underline);
7224 assert!(options.bold);
7225 assert!(!options.italic);
7226 assert!(!options.extend_to_line_end);
7227 }
7228 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7229 }
7230 }
7231
7232 #[test]
7233 fn test_api_add_overlay_with_theme_keys() {
7234 let (mut backend, rx) = create_test_backend();
7235
7236 backend
7237 .execute_js(
7238 r#"
7239 const editor = getEditor();
7240 // Test with theme keys for colors
7241 editor.addOverlay(1, "themed", 0, 10, {
7242 fg: "ui.status_bar_fg",
7243 bg: "editor.selection_bg",
7244 });
7245 "#,
7246 "test.js",
7247 )
7248 .unwrap();
7249
7250 let cmd = rx.try_recv().unwrap();
7251 match cmd {
7252 PluginCommand::AddOverlay {
7253 buffer_id,
7254 namespace,
7255 range,
7256 options,
7257 } => {
7258 use fresh_core::api::OverlayColorSpec;
7259 assert_eq!(buffer_id.0, 1);
7260 assert!(namespace.is_some());
7261 assert_eq!(namespace.unwrap().as_str(), "themed");
7262 assert_eq!(range, 0..10);
7263 assert!(matches!(
7264 &options.fg,
7265 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
7266 ));
7267 assert!(matches!(
7268 &options.bg,
7269 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
7270 ));
7271 assert!(!options.underline);
7272 assert!(!options.bold);
7273 assert!(!options.italic);
7274 assert!(!options.extend_to_line_end);
7275 }
7276 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7277 }
7278 }
7279
7280 #[test]
7281 fn test_api_clear_namespace() {
7282 let (mut backend, rx) = create_test_backend();
7283
7284 backend
7285 .execute_js(
7286 r#"
7287 const editor = getEditor();
7288 editor.clearNamespace(1, "highlight");
7289 "#,
7290 "test.js",
7291 )
7292 .unwrap();
7293
7294 let cmd = rx.try_recv().unwrap();
7295 match cmd {
7296 PluginCommand::ClearNamespace {
7297 buffer_id,
7298 namespace,
7299 } => {
7300 assert_eq!(buffer_id.0, 1);
7301 assert_eq!(namespace.as_str(), "highlight");
7302 }
7303 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
7304 }
7305 }
7306
7307 #[test]
7310 fn test_api_get_theme_schema() {
7311 let (mut backend, _rx) = create_test_backend();
7312
7313 backend
7314 .execute_js(
7315 r#"
7316 const editor = getEditor();
7317 const schema = editor.getThemeSchema();
7318 globalThis._isObject = typeof schema === 'object' && schema !== null;
7319 "#,
7320 "test.js",
7321 )
7322 .unwrap();
7323
7324 backend
7325 .plugin_contexts
7326 .borrow()
7327 .get("test")
7328 .unwrap()
7329 .clone()
7330 .with(|ctx| {
7331 let global = ctx.globals();
7332 let is_object: bool = global.get("_isObject").unwrap();
7333 assert!(is_object);
7335 });
7336 }
7337
7338 #[test]
7339 fn test_api_get_builtin_themes() {
7340 let (mut backend, _rx) = create_test_backend();
7341
7342 backend
7343 .execute_js(
7344 r#"
7345 const editor = getEditor();
7346 const themes = editor.getBuiltinThemes();
7347 globalThis._isObject = typeof themes === 'object' && themes !== null;
7348 "#,
7349 "test.js",
7350 )
7351 .unwrap();
7352
7353 backend
7354 .plugin_contexts
7355 .borrow()
7356 .get("test")
7357 .unwrap()
7358 .clone()
7359 .with(|ctx| {
7360 let global = ctx.globals();
7361 let is_object: bool = global.get("_isObject").unwrap();
7362 assert!(is_object);
7364 });
7365 }
7366
7367 #[test]
7368 fn test_api_apply_theme() {
7369 let (mut backend, rx) = create_test_backend();
7370
7371 backend
7372 .execute_js(
7373 r#"
7374 const editor = getEditor();
7375 editor.applyTheme("dark");
7376 "#,
7377 "test.js",
7378 )
7379 .unwrap();
7380
7381 let cmd = rx.try_recv().unwrap();
7382 match cmd {
7383 PluginCommand::ApplyTheme { theme_name } => {
7384 assert_eq!(theme_name, "dark");
7385 }
7386 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
7387 }
7388 }
7389
7390 #[test]
7391 fn test_api_override_theme_colors_round_trip() {
7392 let (mut backend, rx) = create_test_backend();
7395
7396 backend
7397 .execute_js(
7398 r#"
7399 const editor = getEditor();
7400 editor.overrideThemeColors({
7401 "editor.bg": [10, 20, 30],
7402 "editor.fg": [220, 221, 222],
7403 });
7404 "#,
7405 "test.js",
7406 )
7407 .unwrap();
7408
7409 let cmd = rx.try_recv().unwrap();
7410 match cmd {
7411 PluginCommand::OverrideThemeColors { overrides } => {
7412 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
7413 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
7414 assert_eq!(overrides.len(), 2);
7415 }
7416 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
7417 }
7418 }
7419
7420 #[test]
7421 fn test_api_override_theme_colors_clamps_out_of_range() {
7422 let (mut backend, rx) = create_test_backend();
7423
7424 backend
7425 .execute_js(
7426 r#"
7427 const editor = getEditor();
7428 editor.overrideThemeColors({
7429 "editor.bg": [-5, 300, 128],
7430 });
7431 "#,
7432 "test.js",
7433 )
7434 .unwrap();
7435
7436 match rx.try_recv().unwrap() {
7437 PluginCommand::OverrideThemeColors { overrides } => {
7438 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
7439 }
7440 other => panic!("Expected OverrideThemeColors, got {other:?}"),
7441 }
7442 }
7443
7444 #[test]
7445 fn test_api_override_theme_colors_drops_malformed_entries() {
7446 let (mut backend, rx) = create_test_backend();
7449
7450 backend
7451 .execute_js(
7452 r#"
7453 const editor = getEditor();
7454 editor.overrideThemeColors({
7455 "editor.bg": [1, 2, 3],
7456 "not_an_array": "oops",
7457 "wrong_length": [1, 2],
7458 "floats_are_fine": [10.7, 20.2, 30.9],
7459 });
7460 "#,
7461 "test.js",
7462 )
7463 .unwrap();
7464
7465 match rx.try_recv().unwrap() {
7466 PluginCommand::OverrideThemeColors { overrides } => {
7467 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
7468 assert!(!overrides.contains_key("not_an_array"));
7469 assert!(!overrides.contains_key("wrong_length"));
7470 assert_eq!(
7472 overrides.get("floats_are_fine").copied(),
7473 Some([10, 20, 30])
7474 );
7475 }
7476 other => panic!("Expected OverrideThemeColors, got {other:?}"),
7477 }
7478 }
7479
7480 #[test]
7481 fn test_api_get_theme_data_missing() {
7482 let (mut backend, _rx) = create_test_backend();
7483
7484 backend
7485 .execute_js(
7486 r#"
7487 const editor = getEditor();
7488 const data = editor.getThemeData("nonexistent");
7489 globalThis._isNull = data === null;
7490 "#,
7491 "test.js",
7492 )
7493 .unwrap();
7494
7495 backend
7496 .plugin_contexts
7497 .borrow()
7498 .get("test")
7499 .unwrap()
7500 .clone()
7501 .with(|ctx| {
7502 let global = ctx.globals();
7503 let is_null: bool = global.get("_isNull").unwrap();
7504 assert!(is_null);
7506 });
7507 }
7508
7509 #[test]
7510 fn test_api_get_theme_data_present() {
7511 let (tx, _rx) = mpsc::channel();
7513 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7514 let services = Arc::new(ThemeCacheTestBridge {
7515 inner: TestServiceBridge::new(),
7516 });
7517 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7518
7519 backend
7520 .execute_js(
7521 r#"
7522 const editor = getEditor();
7523 const data = editor.getThemeData("test-theme");
7524 globalThis._hasData = data !== null && typeof data === 'object';
7525 globalThis._name = data ? data.name : null;
7526 "#,
7527 "test.js",
7528 )
7529 .unwrap();
7530
7531 backend
7532 .plugin_contexts
7533 .borrow()
7534 .get("test")
7535 .unwrap()
7536 .clone()
7537 .with(|ctx| {
7538 let global = ctx.globals();
7539 let has_data: bool = global.get("_hasData").unwrap();
7540 assert!(has_data, "getThemeData should return theme object");
7541 let name: String = global.get("_name").unwrap();
7542 assert_eq!(name, "test-theme");
7543 });
7544 }
7545
7546 #[test]
7547 fn test_api_theme_file_exists() {
7548 let (mut backend, _rx) = create_test_backend();
7549
7550 backend
7551 .execute_js(
7552 r#"
7553 const editor = getEditor();
7554 globalThis._exists = editor.themeFileExists("anything");
7555 "#,
7556 "test.js",
7557 )
7558 .unwrap();
7559
7560 backend
7561 .plugin_contexts
7562 .borrow()
7563 .get("test")
7564 .unwrap()
7565 .clone()
7566 .with(|ctx| {
7567 let global = ctx.globals();
7568 let exists: bool = global.get("_exists").unwrap();
7569 assert!(!exists);
7571 });
7572 }
7573
7574 #[test]
7575 fn test_api_save_theme_file_error() {
7576 let (mut backend, _rx) = create_test_backend();
7577
7578 backend
7579 .execute_js(
7580 r#"
7581 const editor = getEditor();
7582 let threw = false;
7583 try {
7584 editor.saveThemeFile("test", "{}");
7585 } catch (e) {
7586 threw = true;
7587 }
7588 globalThis._threw = threw;
7589 "#,
7590 "test.js",
7591 )
7592 .unwrap();
7593
7594 backend
7595 .plugin_contexts
7596 .borrow()
7597 .get("test")
7598 .unwrap()
7599 .clone()
7600 .with(|ctx| {
7601 let global = ctx.globals();
7602 let threw: bool = global.get("_threw").unwrap();
7603 assert!(threw);
7605 });
7606 }
7607
7608 struct ThemeCacheTestBridge {
7610 inner: TestServiceBridge,
7611 }
7612
7613 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
7614 fn as_any(&self) -> &dyn std::any::Any {
7615 self
7616 }
7617 fn translate(
7618 &self,
7619 plugin_name: &str,
7620 key: &str,
7621 args: &HashMap<String, String>,
7622 ) -> String {
7623 self.inner.translate(plugin_name, key, args)
7624 }
7625 fn current_locale(&self) -> String {
7626 self.inner.current_locale()
7627 }
7628 fn set_js_execution_state(&self, state: String) {
7629 self.inner.set_js_execution_state(state);
7630 }
7631 fn clear_js_execution_state(&self) {
7632 self.inner.clear_js_execution_state();
7633 }
7634 fn get_theme_schema(&self) -> serde_json::Value {
7635 self.inner.get_theme_schema()
7636 }
7637 fn get_builtin_themes(&self) -> serde_json::Value {
7638 self.inner.get_builtin_themes()
7639 }
7640 fn get_all_themes(&self) -> serde_json::Value {
7641 self.inner.get_all_themes()
7642 }
7643 fn register_command(&self, command: fresh_core::command::Command) {
7644 self.inner.register_command(command);
7645 }
7646 fn unregister_command(&self, name: &str) {
7647 self.inner.unregister_command(name);
7648 }
7649 fn unregister_commands_by_prefix(&self, prefix: &str) {
7650 self.inner.unregister_commands_by_prefix(prefix);
7651 }
7652 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
7653 self.inner.unregister_commands_by_plugin(plugin_name);
7654 }
7655 fn plugins_dir(&self) -> std::path::PathBuf {
7656 self.inner.plugins_dir()
7657 }
7658 fn config_dir(&self) -> std::path::PathBuf {
7659 self.inner.config_dir()
7660 }
7661 fn data_dir(&self) -> std::path::PathBuf {
7662 self.inner.data_dir()
7663 }
7664 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
7665 if name == "test-theme" {
7666 Some(serde_json::json!({
7667 "name": "test-theme",
7668 "editor": {},
7669 "ui": {},
7670 "syntax": {}
7671 }))
7672 } else {
7673 None
7674 }
7675 }
7676 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7677 Err("test bridge does not support save".to_string())
7678 }
7679 fn theme_file_exists(&self, name: &str) -> bool {
7680 name == "test-theme"
7681 }
7682 }
7683
7684 #[test]
7687 fn test_api_close_buffer() {
7688 let (mut backend, rx) = create_test_backend();
7689
7690 backend
7691 .execute_js(
7692 r#"
7693 const editor = getEditor();
7694 editor.closeBuffer(3);
7695 "#,
7696 "test.js",
7697 )
7698 .unwrap();
7699
7700 let cmd = rx.try_recv().unwrap();
7701 match cmd {
7702 PluginCommand::CloseBuffer { buffer_id } => {
7703 assert_eq!(buffer_id.0, 3);
7704 }
7705 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
7706 }
7707 }
7708
7709 #[test]
7710 fn test_api_focus_split() {
7711 let (mut backend, rx) = create_test_backend();
7712
7713 backend
7714 .execute_js(
7715 r#"
7716 const editor = getEditor();
7717 editor.focusSplit(2);
7718 "#,
7719 "test.js",
7720 )
7721 .unwrap();
7722
7723 let cmd = rx.try_recv().unwrap();
7724 match cmd {
7725 PluginCommand::FocusSplit { split_id } => {
7726 assert_eq!(split_id.0, 2);
7727 }
7728 _ => panic!("Expected FocusSplit, got {:?}", cmd),
7729 }
7730 }
7731
7732 #[test]
7733 fn test_api_list_buffers() {
7734 let (tx, _rx) = mpsc::channel();
7735 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7736
7737 {
7739 let mut state = state_snapshot.write().unwrap();
7740 state.buffers.insert(
7741 BufferId(0),
7742 BufferInfo {
7743 id: BufferId(0),
7744 path: Some(PathBuf::from("/test1.txt")),
7745 modified: false,
7746 length: 100,
7747 is_virtual: false,
7748 view_mode: "source".to_string(),
7749 is_composing_in_any_split: false,
7750 compose_width: None,
7751 language: "text".to_string(),
7752 is_preview: false,
7753 splits: Vec::new(),
7754 },
7755 );
7756 state.buffers.insert(
7757 BufferId(1),
7758 BufferInfo {
7759 id: BufferId(1),
7760 path: Some(PathBuf::from("/test2.txt")),
7761 modified: true,
7762 length: 200,
7763 is_virtual: false,
7764 view_mode: "source".to_string(),
7765 is_composing_in_any_split: false,
7766 compose_width: None,
7767 language: "text".to_string(),
7768 is_preview: false,
7769 splits: Vec::new(),
7770 },
7771 );
7772 }
7773
7774 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7775 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7776
7777 backend
7778 .execute_js(
7779 r#"
7780 const editor = getEditor();
7781 const buffers = editor.listBuffers();
7782 globalThis._isArray = Array.isArray(buffers);
7783 globalThis._length = buffers.length;
7784 "#,
7785 "test.js",
7786 )
7787 .unwrap();
7788
7789 backend
7790 .plugin_contexts
7791 .borrow()
7792 .get("test")
7793 .unwrap()
7794 .clone()
7795 .with(|ctx| {
7796 let global = ctx.globals();
7797 let is_array: bool = global.get("_isArray").unwrap();
7798 let length: u32 = global.get("_length").unwrap();
7799 assert!(is_array);
7800 assert_eq!(length, 2);
7801 });
7802 }
7803
7804 #[test]
7807 fn test_api_start_prompt() {
7808 let (mut backend, rx) = create_test_backend();
7809
7810 backend
7811 .execute_js(
7812 r#"
7813 const editor = getEditor();
7814 editor.startPrompt("Enter value:", "test-prompt");
7815 "#,
7816 "test.js",
7817 )
7818 .unwrap();
7819
7820 let cmd = rx.try_recv().unwrap();
7821 match cmd {
7822 PluginCommand::StartPrompt {
7823 label,
7824 prompt_type,
7825 floating_overlay,
7826 } => {
7827 assert_eq!(label, "Enter value:");
7828 assert_eq!(prompt_type, "test-prompt");
7829 assert!(!floating_overlay);
7830 }
7831 _ => panic!("Expected StartPrompt, got {:?}", cmd),
7832 }
7833 }
7834
7835 #[test]
7836 fn test_api_start_prompt_with_initial() {
7837 let (mut backend, rx) = create_test_backend();
7838
7839 backend
7840 .execute_js(
7841 r#"
7842 const editor = getEditor();
7843 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
7844 "#,
7845 "test.js",
7846 )
7847 .unwrap();
7848
7849 let cmd = rx.try_recv().unwrap();
7850 match cmd {
7851 PluginCommand::StartPromptWithInitial {
7852 label,
7853 prompt_type,
7854 initial_value,
7855 floating_overlay,
7856 } => {
7857 assert_eq!(label, "Enter value:");
7858 assert_eq!(prompt_type, "test-prompt");
7859 assert_eq!(initial_value, "default");
7860 assert!(!floating_overlay);
7861 }
7862 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
7863 }
7864 }
7865
7866 #[test]
7867 fn test_api_set_prompt_suggestions() {
7868 let (mut backend, rx) = create_test_backend();
7869
7870 backend
7871 .execute_js(
7872 r#"
7873 const editor = getEditor();
7874 editor.setPromptSuggestions([
7875 { text: "Option 1", value: "opt1" },
7876 { text: "Option 2", value: "opt2" }
7877 ]);
7878 "#,
7879 "test.js",
7880 )
7881 .unwrap();
7882
7883 let cmd = rx.try_recv().unwrap();
7884 match cmd {
7885 PluginCommand::SetPromptSuggestions { suggestions } => {
7886 assert_eq!(suggestions.len(), 2);
7887 assert_eq!(suggestions[0].text, "Option 1");
7888 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
7889 }
7890 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
7891 }
7892 }
7893
7894 #[test]
7897 fn test_api_get_active_buffer_id() {
7898 let (tx, _rx) = mpsc::channel();
7899 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7900
7901 {
7902 let mut state = state_snapshot.write().unwrap();
7903 state.active_buffer_id = BufferId(42);
7904 }
7905
7906 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7907 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7908
7909 backend
7910 .execute_js(
7911 r#"
7912 const editor = getEditor();
7913 globalThis._activeId = editor.getActiveBufferId();
7914 "#,
7915 "test.js",
7916 )
7917 .unwrap();
7918
7919 backend
7920 .plugin_contexts
7921 .borrow()
7922 .get("test")
7923 .unwrap()
7924 .clone()
7925 .with(|ctx| {
7926 let global = ctx.globals();
7927 let result: u32 = global.get("_activeId").unwrap();
7928 assert_eq!(result, 42);
7929 });
7930 }
7931
7932 #[test]
7933 fn test_api_get_active_split_id() {
7934 let (tx, _rx) = mpsc::channel();
7935 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7936
7937 {
7938 let mut state = state_snapshot.write().unwrap();
7939 state.active_split_id = 7;
7940 }
7941
7942 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7943 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7944
7945 backend
7946 .execute_js(
7947 r#"
7948 const editor = getEditor();
7949 globalThis._splitId = editor.getActiveSplitId();
7950 "#,
7951 "test.js",
7952 )
7953 .unwrap();
7954
7955 backend
7956 .plugin_contexts
7957 .borrow()
7958 .get("test")
7959 .unwrap()
7960 .clone()
7961 .with(|ctx| {
7962 let global = ctx.globals();
7963 let result: u32 = global.get("_splitId").unwrap();
7964 assert_eq!(result, 7);
7965 });
7966 }
7967
7968 #[test]
7971 fn test_api_file_exists() {
7972 let (mut backend, _rx) = create_test_backend();
7973
7974 backend
7975 .execute_js(
7976 r#"
7977 const editor = getEditor();
7978 // Test with a path that definitely exists
7979 globalThis._exists = editor.fileExists("/");
7980 "#,
7981 "test.js",
7982 )
7983 .unwrap();
7984
7985 backend
7986 .plugin_contexts
7987 .borrow()
7988 .get("test")
7989 .unwrap()
7990 .clone()
7991 .with(|ctx| {
7992 let global = ctx.globals();
7993 let result: bool = global.get("_exists").unwrap();
7994 assert!(result);
7995 });
7996 }
7997
7998 #[test]
7999 fn test_api_parse_jsonc() {
8000 let (mut backend, _rx) = create_test_backend();
8001
8002 backend
8003 .execute_js(
8004 r#"
8005 const editor = getEditor();
8006 // Comments, trailing commas, and nested structures should all parse.
8007 const parsed = editor.parseJsonc(`{
8008 // name of the container
8009 "name": "test",
8010 "features": {
8011 "docker-in-docker": {},
8012 },
8013 /* forwarded port list */
8014 "forwardPorts": [3000, 8080,],
8015 }`);
8016 globalThis._name = parsed.name;
8017 globalThis._featureCount = Object.keys(parsed.features).length;
8018 globalThis._portCount = parsed.forwardPorts.length;
8019
8020 // Invalid JSONC should throw.
8021 try {
8022 editor.parseJsonc("{ broken");
8023 globalThis._threw = false;
8024 } catch (_e) {
8025 globalThis._threw = true;
8026 }
8027 "#,
8028 "test.js",
8029 )
8030 .unwrap();
8031
8032 backend
8033 .plugin_contexts
8034 .borrow()
8035 .get("test")
8036 .unwrap()
8037 .clone()
8038 .with(|ctx| {
8039 let global = ctx.globals();
8040 let name: String = global.get("_name").unwrap();
8041 let feature_count: u32 = global.get("_featureCount").unwrap();
8042 let port_count: u32 = global.get("_portCount").unwrap();
8043 let threw: bool = global.get("_threw").unwrap();
8044 assert_eq!(name, "test");
8045 assert_eq!(feature_count, 1);
8046 assert_eq!(port_count, 2);
8047 assert!(threw, "Invalid JSONC should throw");
8048 });
8049 }
8050
8051 #[test]
8052 fn test_api_get_cwd() {
8053 let (mut backend, _rx) = create_test_backend();
8054
8055 backend
8056 .execute_js(
8057 r#"
8058 const editor = getEditor();
8059 globalThis._cwd = editor.getCwd();
8060 "#,
8061 "test.js",
8062 )
8063 .unwrap();
8064
8065 backend
8066 .plugin_contexts
8067 .borrow()
8068 .get("test")
8069 .unwrap()
8070 .clone()
8071 .with(|ctx| {
8072 let global = ctx.globals();
8073 let result: String = global.get("_cwd").unwrap();
8074 assert!(!result.is_empty());
8076 });
8077 }
8078
8079 #[test]
8080 fn test_api_get_env() {
8081 let (mut backend, _rx) = create_test_backend();
8082
8083 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
8085
8086 backend
8087 .execute_js(
8088 r#"
8089 const editor = getEditor();
8090 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
8091 "#,
8092 "test.js",
8093 )
8094 .unwrap();
8095
8096 backend
8097 .plugin_contexts
8098 .borrow()
8099 .get("test")
8100 .unwrap()
8101 .clone()
8102 .with(|ctx| {
8103 let global = ctx.globals();
8104 let result: Option<String> = global.get("_envVal").unwrap();
8105 assert_eq!(result, Some("test_value".to_string()));
8106 });
8107
8108 std::env::remove_var("TEST_PLUGIN_VAR");
8109 }
8110
8111 #[test]
8112 fn test_api_get_config() {
8113 let (mut backend, _rx) = create_test_backend();
8114
8115 backend
8116 .execute_js(
8117 r#"
8118 const editor = getEditor();
8119 const config = editor.getConfig();
8120 globalThis._isObject = typeof config === 'object';
8121 "#,
8122 "test.js",
8123 )
8124 .unwrap();
8125
8126 backend
8127 .plugin_contexts
8128 .borrow()
8129 .get("test")
8130 .unwrap()
8131 .clone()
8132 .with(|ctx| {
8133 let global = ctx.globals();
8134 let is_object: bool = global.get("_isObject").unwrap();
8135 assert!(is_object);
8137 });
8138 }
8139
8140 #[test]
8141 fn test_api_get_themes_dir() {
8142 let (mut backend, _rx) = create_test_backend();
8143
8144 backend
8145 .execute_js(
8146 r#"
8147 const editor = getEditor();
8148 globalThis._themesDir = editor.getThemesDir();
8149 "#,
8150 "test.js",
8151 )
8152 .unwrap();
8153
8154 backend
8155 .plugin_contexts
8156 .borrow()
8157 .get("test")
8158 .unwrap()
8159 .clone()
8160 .with(|ctx| {
8161 let global = ctx.globals();
8162 let result: String = global.get("_themesDir").unwrap();
8163 assert!(!result.is_empty());
8165 });
8166 }
8167
8168 #[test]
8171 fn test_api_read_dir() {
8172 let (mut backend, _rx) = create_test_backend();
8173
8174 backend
8175 .execute_js(
8176 r#"
8177 const editor = getEditor();
8178 const entries = editor.readDir("/tmp");
8179 globalThis._isArray = Array.isArray(entries);
8180 globalThis._length = entries.length;
8181 "#,
8182 "test.js",
8183 )
8184 .unwrap();
8185
8186 backend
8187 .plugin_contexts
8188 .borrow()
8189 .get("test")
8190 .unwrap()
8191 .clone()
8192 .with(|ctx| {
8193 let global = ctx.globals();
8194 let is_array: bool = global.get("_isArray").unwrap();
8195 let length: u32 = global.get("_length").unwrap();
8196 assert!(is_array);
8198 let _ = length;
8200 });
8201 }
8202
8203 #[test]
8206 fn test_api_execute_action() {
8207 let (mut backend, rx) = create_test_backend();
8208
8209 backend
8210 .execute_js(
8211 r#"
8212 const editor = getEditor();
8213 editor.executeAction("move_cursor_up");
8214 "#,
8215 "test.js",
8216 )
8217 .unwrap();
8218
8219 let cmd = rx.try_recv().unwrap();
8220 match cmd {
8221 PluginCommand::ExecuteAction { action_name } => {
8222 assert_eq!(action_name, "move_cursor_up");
8223 }
8224 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
8225 }
8226 }
8227
8228 #[test]
8231 fn test_api_debug() {
8232 let (mut backend, _rx) = create_test_backend();
8233
8234 backend
8236 .execute_js(
8237 r#"
8238 const editor = getEditor();
8239 editor.debug("Test debug message");
8240 editor.debug("Another message with special chars: <>&\"'");
8241 "#,
8242 "test.js",
8243 )
8244 .unwrap();
8245 }
8247
8248 #[test]
8251 fn test_typescript_preamble_generated() {
8252 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
8254 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
8255 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
8256 println!(
8257 "Generated {} bytes of TypeScript preamble",
8258 JSEDITORAPI_TS_PREAMBLE.len()
8259 );
8260 }
8261
8262 #[test]
8263 fn test_typescript_editor_api_generated() {
8264 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
8266 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
8267 println!(
8268 "Generated {} bytes of EditorAPI interface",
8269 JSEDITORAPI_TS_EDITOR_API.len()
8270 );
8271 }
8272
8273 #[test]
8274 fn test_js_methods_list() {
8275 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
8277 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
8278 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
8280 if i < 20 {
8281 println!(" - {}", method);
8282 }
8283 }
8284 if JSEDITORAPI_JS_METHODS.len() > 20 {
8285 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
8286 }
8287 }
8288
8289 #[test]
8292 fn test_api_load_plugin_sends_command() {
8293 let (mut backend, rx) = create_test_backend();
8294
8295 backend
8297 .execute_js(
8298 r#"
8299 const editor = getEditor();
8300 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
8301 "#,
8302 "test.js",
8303 )
8304 .unwrap();
8305
8306 let cmd = rx.try_recv().unwrap();
8308 match cmd {
8309 PluginCommand::LoadPlugin { path, callback_id } => {
8310 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
8311 assert!(callback_id.0 > 0); }
8313 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
8314 }
8315 }
8316
8317 #[test]
8318 fn test_api_unload_plugin_sends_command() {
8319 let (mut backend, rx) = create_test_backend();
8320
8321 backend
8323 .execute_js(
8324 r#"
8325 const editor = getEditor();
8326 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
8327 "#,
8328 "test.js",
8329 )
8330 .unwrap();
8331
8332 let cmd = rx.try_recv().unwrap();
8334 match cmd {
8335 PluginCommand::UnloadPlugin { name, callback_id } => {
8336 assert_eq!(name, "my-plugin");
8337 assert!(callback_id.0 > 0); }
8339 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
8340 }
8341 }
8342
8343 #[test]
8344 fn test_api_reload_plugin_sends_command() {
8345 let (mut backend, rx) = create_test_backend();
8346
8347 backend
8349 .execute_js(
8350 r#"
8351 const editor = getEditor();
8352 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
8353 "#,
8354 "test.js",
8355 )
8356 .unwrap();
8357
8358 let cmd = rx.try_recv().unwrap();
8360 match cmd {
8361 PluginCommand::ReloadPlugin { name, callback_id } => {
8362 assert_eq!(name, "my-plugin");
8363 assert!(callback_id.0 > 0); }
8365 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
8366 }
8367 }
8368
8369 #[test]
8370 fn test_api_load_plugin_resolves_callback() {
8371 let (mut backend, rx) = create_test_backend();
8372
8373 backend
8375 .execute_js(
8376 r#"
8377 const editor = getEditor();
8378 globalThis._loadResult = null;
8379 editor.loadPlugin("/path/to/plugin.ts").then(result => {
8380 globalThis._loadResult = result;
8381 });
8382 "#,
8383 "test.js",
8384 )
8385 .unwrap();
8386
8387 let callback_id = match rx.try_recv().unwrap() {
8389 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
8390 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
8391 };
8392
8393 backend.resolve_callback(callback_id, "true");
8395
8396 backend
8398 .plugin_contexts
8399 .borrow()
8400 .get("test")
8401 .unwrap()
8402 .clone()
8403 .with(|ctx| {
8404 run_pending_jobs_checked(&ctx, "test async loadPlugin");
8405 });
8406
8407 backend
8409 .plugin_contexts
8410 .borrow()
8411 .get("test")
8412 .unwrap()
8413 .clone()
8414 .with(|ctx| {
8415 let global = ctx.globals();
8416 let result: bool = global.get("_loadResult").unwrap();
8417 assert!(result);
8418 });
8419 }
8420
8421 #[test]
8422 fn test_api_version() {
8423 let (mut backend, _rx) = create_test_backend();
8424
8425 backend
8426 .execute_js(
8427 r#"
8428 const editor = getEditor();
8429 globalThis._apiVersion = editor.apiVersion();
8430 "#,
8431 "test.js",
8432 )
8433 .unwrap();
8434
8435 backend
8436 .plugin_contexts
8437 .borrow()
8438 .get("test")
8439 .unwrap()
8440 .clone()
8441 .with(|ctx| {
8442 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
8443 assert_eq!(version, 2);
8444 });
8445 }
8446
8447 #[test]
8448 fn test_api_unload_plugin_rejects_on_error() {
8449 let (mut backend, rx) = create_test_backend();
8450
8451 backend
8453 .execute_js(
8454 r#"
8455 const editor = getEditor();
8456 globalThis._unloadError = null;
8457 editor.unloadPlugin("nonexistent-plugin").catch(err => {
8458 globalThis._unloadError = err.message || String(err);
8459 });
8460 "#,
8461 "test.js",
8462 )
8463 .unwrap();
8464
8465 let callback_id = match rx.try_recv().unwrap() {
8467 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
8468 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
8469 };
8470
8471 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
8473
8474 backend
8476 .plugin_contexts
8477 .borrow()
8478 .get("test")
8479 .unwrap()
8480 .clone()
8481 .with(|ctx| {
8482 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
8483 });
8484
8485 backend
8487 .plugin_contexts
8488 .borrow()
8489 .get("test")
8490 .unwrap()
8491 .clone()
8492 .with(|ctx| {
8493 let global = ctx.globals();
8494 let error: String = global.get("_unloadError").unwrap();
8495 assert!(error.contains("nonexistent-plugin"));
8496 });
8497 }
8498
8499 #[test]
8500 fn test_api_set_global_state() {
8501 let (mut backend, rx) = create_test_backend();
8502
8503 backend
8504 .execute_js(
8505 r#"
8506 const editor = getEditor();
8507 editor.setGlobalState("myKey", { enabled: true, count: 42 });
8508 "#,
8509 "test_plugin.js",
8510 )
8511 .unwrap();
8512
8513 let cmd = rx.try_recv().unwrap();
8514 match cmd {
8515 PluginCommand::SetGlobalState {
8516 plugin_name,
8517 key,
8518 value,
8519 } => {
8520 assert_eq!(plugin_name, "test_plugin");
8521 assert_eq!(key, "myKey");
8522 let v = value.unwrap();
8523 assert_eq!(v["enabled"], serde_json::json!(true));
8524 assert_eq!(v["count"], serde_json::json!(42));
8525 }
8526 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
8527 }
8528 }
8529
8530 #[test]
8531 fn test_api_set_global_state_delete() {
8532 let (mut backend, rx) = create_test_backend();
8533
8534 backend
8535 .execute_js(
8536 r#"
8537 const editor = getEditor();
8538 editor.setGlobalState("myKey", null);
8539 "#,
8540 "test_plugin.js",
8541 )
8542 .unwrap();
8543
8544 let cmd = rx.try_recv().unwrap();
8545 match cmd {
8546 PluginCommand::SetGlobalState {
8547 plugin_name,
8548 key,
8549 value,
8550 } => {
8551 assert_eq!(plugin_name, "test_plugin");
8552 assert_eq!(key, "myKey");
8553 assert!(value.is_none(), "null should delete the key");
8554 }
8555 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
8556 }
8557 }
8558
8559 #[test]
8560 fn test_api_get_global_state_roundtrip() {
8561 let (mut backend, _rx) = create_test_backend();
8562
8563 backend
8565 .execute_js(
8566 r#"
8567 const editor = getEditor();
8568 editor.setGlobalState("flag", true);
8569 globalThis._result = editor.getGlobalState("flag");
8570 "#,
8571 "test_plugin.js",
8572 )
8573 .unwrap();
8574
8575 backend
8576 .plugin_contexts
8577 .borrow()
8578 .get("test_plugin")
8579 .unwrap()
8580 .clone()
8581 .with(|ctx| {
8582 let global = ctx.globals();
8583 let result: bool = global.get("_result").unwrap();
8584 assert!(
8585 result,
8586 "getGlobalState should return the value set by setGlobalState"
8587 );
8588 });
8589 }
8590
8591 #[test]
8592 fn test_api_get_global_state_missing_key() {
8593 let (mut backend, _rx) = create_test_backend();
8594
8595 backend
8596 .execute_js(
8597 r#"
8598 const editor = getEditor();
8599 globalThis._result = editor.getGlobalState("nonexistent");
8600 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
8601 "#,
8602 "test_plugin.js",
8603 )
8604 .unwrap();
8605
8606 backend
8607 .plugin_contexts
8608 .borrow()
8609 .get("test_plugin")
8610 .unwrap()
8611 .clone()
8612 .with(|ctx| {
8613 let global = ctx.globals();
8614 let is_undefined: bool = global.get("_isUndefined").unwrap();
8615 assert!(
8616 is_undefined,
8617 "getGlobalState for missing key should return undefined"
8618 );
8619 });
8620 }
8621
8622 #[test]
8623 fn test_api_global_state_isolation_between_plugins() {
8624 let (tx, _rx) = mpsc::channel();
8626 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8627 let services = Arc::new(TestServiceBridge::new());
8628
8629 let mut backend_a =
8631 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
8632 .unwrap();
8633 backend_a
8634 .execute_js(
8635 r#"
8636 const editor = getEditor();
8637 editor.setGlobalState("flag", "from_plugin_a");
8638 "#,
8639 "plugin_a.js",
8640 )
8641 .unwrap();
8642
8643 let mut backend_b =
8645 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
8646 .unwrap();
8647 backend_b
8648 .execute_js(
8649 r#"
8650 const editor = getEditor();
8651 editor.setGlobalState("flag", "from_plugin_b");
8652 "#,
8653 "plugin_b.js",
8654 )
8655 .unwrap();
8656
8657 backend_a
8659 .execute_js(
8660 r#"
8661 const editor = getEditor();
8662 globalThis._aValue = editor.getGlobalState("flag");
8663 "#,
8664 "plugin_a.js",
8665 )
8666 .unwrap();
8667
8668 backend_a
8669 .plugin_contexts
8670 .borrow()
8671 .get("plugin_a")
8672 .unwrap()
8673 .clone()
8674 .with(|ctx| {
8675 let global = ctx.globals();
8676 let a_value: String = global.get("_aValue").unwrap();
8677 assert_eq!(
8678 a_value, "from_plugin_a",
8679 "Plugin A should see its own value, not plugin B's"
8680 );
8681 });
8682
8683 backend_b
8685 .execute_js(
8686 r#"
8687 const editor = getEditor();
8688 globalThis._bValue = editor.getGlobalState("flag");
8689 "#,
8690 "plugin_b.js",
8691 )
8692 .unwrap();
8693
8694 backend_b
8695 .plugin_contexts
8696 .borrow()
8697 .get("plugin_b")
8698 .unwrap()
8699 .clone()
8700 .with(|ctx| {
8701 let global = ctx.globals();
8702 let b_value: String = global.get("_bValue").unwrap();
8703 assert_eq!(
8704 b_value, "from_plugin_b",
8705 "Plugin B should see its own value, not plugin A's"
8706 );
8707 });
8708 }
8709
8710 #[test]
8711 fn test_register_command_collision_different_plugins() {
8712 let (mut backend, _rx) = create_test_backend();
8713
8714 backend
8716 .execute_js(
8717 r#"
8718 const editor = getEditor();
8719 globalThis.handlerA = function() { };
8720 editor.registerCommand("My Command", "From A", "handlerA", null);
8721 "#,
8722 "plugin_a.js",
8723 )
8724 .unwrap();
8725
8726 let result = backend.execute_js(
8728 r#"
8729 const editor = getEditor();
8730 globalThis.handlerB = function() { };
8731 editor.registerCommand("My Command", "From B", "handlerB", null);
8732 "#,
8733 "plugin_b.js",
8734 );
8735
8736 assert!(
8737 result.is_err(),
8738 "Second plugin registering the same command name should fail"
8739 );
8740 let err_msg = result.unwrap_err().to_string();
8741 assert!(
8742 err_msg.contains("already registered"),
8743 "Error should mention collision: {}",
8744 err_msg
8745 );
8746 }
8747
8748 #[test]
8749 fn test_register_command_same_plugin_allowed() {
8750 let (mut backend, _rx) = create_test_backend();
8751
8752 backend
8754 .execute_js(
8755 r#"
8756 const editor = getEditor();
8757 globalThis.handler1 = function() { };
8758 editor.registerCommand("My Command", "Version 1", "handler1", null);
8759 globalThis.handler2 = function() { };
8760 editor.registerCommand("My Command", "Version 2", "handler2", null);
8761 "#,
8762 "plugin_a.js",
8763 )
8764 .unwrap();
8765 }
8766
8767 #[test]
8768 fn test_register_command_after_unregister() {
8769 let (mut backend, _rx) = create_test_backend();
8770
8771 backend
8773 .execute_js(
8774 r#"
8775 const editor = getEditor();
8776 globalThis.handlerA = function() { };
8777 editor.registerCommand("My Command", "From A", "handlerA", null);
8778 editor.unregisterCommand("My Command");
8779 "#,
8780 "plugin_a.js",
8781 )
8782 .unwrap();
8783
8784 backend
8786 .execute_js(
8787 r#"
8788 const editor = getEditor();
8789 globalThis.handlerB = function() { };
8790 editor.registerCommand("My Command", "From B", "handlerB", null);
8791 "#,
8792 "plugin_b.js",
8793 )
8794 .unwrap();
8795 }
8796
8797 #[test]
8798 fn test_register_command_collision_caught_in_try_catch() {
8799 let (mut backend, _rx) = create_test_backend();
8800
8801 backend
8803 .execute_js(
8804 r#"
8805 const editor = getEditor();
8806 globalThis.handlerA = function() { };
8807 editor.registerCommand("My Command", "From A", "handlerA", null);
8808 "#,
8809 "plugin_a.js",
8810 )
8811 .unwrap();
8812
8813 backend
8815 .execute_js(
8816 r#"
8817 const editor = getEditor();
8818 globalThis.handlerB = function() { };
8819 let caught = false;
8820 try {
8821 editor.registerCommand("My Command", "From B", "handlerB", null);
8822 } catch (e) {
8823 caught = true;
8824 }
8825 if (!caught) throw new Error("Expected collision error");
8826 "#,
8827 "plugin_b.js",
8828 )
8829 .unwrap();
8830 }
8831
8832 #[test]
8833 fn test_register_command_i18n_key_no_collision_across_plugins() {
8834 let (mut backend, _rx) = create_test_backend();
8835
8836 backend
8838 .execute_js(
8839 r#"
8840 const editor = getEditor();
8841 globalThis.handlerA = function() { };
8842 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
8843 "#,
8844 "plugin_a.js",
8845 )
8846 .unwrap();
8847
8848 backend
8851 .execute_js(
8852 r#"
8853 const editor = getEditor();
8854 globalThis.handlerB = function() { };
8855 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
8856 "#,
8857 "plugin_b.js",
8858 )
8859 .unwrap();
8860 }
8861
8862 #[test]
8863 fn test_register_command_non_i18n_still_collides() {
8864 let (mut backend, _rx) = create_test_backend();
8865
8866 backend
8868 .execute_js(
8869 r#"
8870 const editor = getEditor();
8871 globalThis.handlerA = function() { };
8872 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
8873 "#,
8874 "plugin_a.js",
8875 )
8876 .unwrap();
8877
8878 let result = backend.execute_js(
8880 r#"
8881 const editor = getEditor();
8882 globalThis.handlerB = function() { };
8883 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
8884 "#,
8885 "plugin_b.js",
8886 );
8887
8888 assert!(
8889 result.is_err(),
8890 "Non-%-prefixed names should still collide across plugins"
8891 );
8892 }
8893}