1use std::any::Any;
69use std::path::{Path, PathBuf};
70
71use mlua::{Function, Lua, RegistryKey, Result as LuaResult, Table, Value, Variadic};
72use parking_lot::Mutex;
73use rustc_hash::FxHashMap;
74
75use crate::theme::{ThemeBase, ThemeColors, ThemeDefinition};
76use crate::traits::{CommandConfig, KeybindingConfig, Plugin, PluginCapabilities};
77use crate::types::{
78 CustomChartConfig, CustomTableConfig, GaugePaneConfig, LogLevel, PluginContext, StatPaneConfig,
79 TableColumnConfig,
80};
81use crate::{PluginError, PluginResult};
82
83struct LuaCommand {
85 name: String,
87 aliases: Vec<String>,
89 description: String,
91 accepts_args: bool,
93 callback_key: RegistryKey,
95}
96
97fn setup_noop_api(lua: &Lua, enya: &Table) -> LuaResult<()> {
101 enya.set(
103 "notify",
104 lua.create_function(|_, _: (String, String)| Ok(()))?,
105 )?;
106 enya.set("log", lua.create_function(|_, _: (String, String)| Ok(()))?)?;
107 enya.set("request_repaint", lua.create_function(|_, ()| Ok(()))?)?;
108 enya.set(
109 "editor_version",
110 lua.create_function(|_, ()| Ok("unknown".to_string()))?,
111 )?;
112 enya.set("is_wasm", lua.create_function(|_, ()| Ok(false))?)?;
113 enya.set(
114 "theme_name",
115 lua.create_function(|_, ()| Ok("unknown".to_string()))?,
116 )?;
117 enya.set(
118 "clipboard_write",
119 lua.create_function(|_, _: String| Ok(false))?,
120 )?;
121 enya.set(
122 "clipboard_read",
123 lua.create_function(|_, ()| Ok(None::<String>))?,
124 )?;
125 enya.set(
126 "execute",
127 lua.create_function(|_, _: (String, Option<String>)| Ok(false))?,
128 )?;
129
130 enya.set(
132 "http_get",
133 lua.create_function(|_, _: (String, Option<Table>)| -> LuaResult<Table> {
134 Err(mlua::Error::runtime(
135 "HTTP not available during plugin loading",
136 ))
137 })?,
138 )?;
139 enya.set(
140 "http_post",
141 lua.create_function(
142 |_, _: (String, String, Option<Table>)| -> LuaResult<Table> {
143 Err(mlua::Error::runtime(
144 "HTTP not available during plugin loading",
145 ))
146 },
147 )?,
148 )?;
149
150 enya.set(
152 "add_query_pane",
153 lua.create_function(|_, _: (String, Option<String>)| Ok(()))?,
154 )?;
155 enya.set("add_logs_pane", lua.create_function(|_, ()| Ok(()))?)?;
156 enya.set(
157 "add_tracing_pane",
158 lua.create_function(|_, _: Option<String>| Ok(()))?,
159 )?;
160 enya.set("add_terminal_pane", lua.create_function(|_, ()| Ok(()))?)?;
161 enya.set("add_sql_pane", lua.create_function(|_, ()| Ok(()))?)?;
162 enya.set("close_pane", lua.create_function(|_, ()| Ok(()))?)?;
163 enya.set("focus_pane", lua.create_function(|_, _: String| Ok(()))?)?;
164
165 enya.set(
167 "set_time_range",
168 lua.create_function(|_, _: String| Ok(()))?,
169 )?;
170 enya.set(
171 "set_time_range_absolute",
172 lua.create_function(|_, _: (f64, f64)| Ok(()))?,
173 )?;
174 enya.set(
175 "get_time_range",
176 lua.create_function(|lua, ()| {
177 let table = lua.create_table()?;
178 table.set("start", 0.0)?;
179 table.set("end", 0.0)?;
180 Ok(table)
181 })?,
182 )?;
183 enya.set(
184 "get_focused_pane",
185 lua.create_function(|_, ()| Ok(None::<Table>))?,
186 )?;
187
188 enya.set(
190 "add_custom_pane",
191 lua.create_function(|_, _: String| Ok(()))?,
192 )?;
193 enya.set(
194 "set_pane_data",
195 lua.create_function(|_, _: (String, Table)| Ok(()))?,
196 )?;
197 enya.set(
198 "add_chart_pane",
199 lua.create_function(|_, _: String| Ok(()))?,
200 )?;
201 enya.set(
202 "set_chart_data",
203 lua.create_function(|_, _: (String, Table)| Ok(()))?,
204 )?;
205 enya.set("add_stat_pane", lua.create_function(|_, _: String| Ok(()))?)?;
206 enya.set(
207 "set_stat_data",
208 lua.create_function(|_, _: (String, Table)| Ok(()))?,
209 )?;
210 enya.set(
211 "add_gauge_pane",
212 lua.create_function(|_, _: String| Ok(()))?,
213 )?;
214 enya.set(
215 "set_gauge_data",
216 lua.create_function(|_, _: (String, Table)| Ok(()))?,
217 )?;
218
219 Ok(())
220}
221
222pub struct LuaPlugin {
224 lua: Mutex<Lua>,
226 name: &'static str,
228 version: &'static str,
230 description: &'static str,
232 commands: Vec<LuaCommand>,
234 keybindings: Vec<KeybindingConfig>,
236 table_pane_types: Vec<CustomTableConfig>,
238 chart_pane_types: Vec<CustomChartConfig>,
240 stat_pane_types: Vec<StatPaneConfig>,
242 gauge_pane_types: Vec<GaugePaneConfig>,
244 refresh_callbacks: FxHashMap<String, RegistryKey>,
246 theme: Option<ThemeDefinition>,
248 path: PathBuf,
250 active: bool,
252 on_activate_key: Option<RegistryKey>,
254 on_deactivate_key: Option<RegistryKey>,
256}
257
258impl LuaPlugin {
259 pub fn load(path: &Path) -> PluginResult<Self> {
261 let content = std::fs::read_to_string(path).map_err(|e| {
262 PluginError::InitializationFailed(format!("Failed to read {}: {e}", path.display()))
263 })?;
264
265 Self::load_from_source(&content, path)
266 }
267
268 pub fn load_from_source(source: &str, path: &Path) -> PluginResult<Self> {
270 let lua = Lua::new();
271
272 Self::setup_registration_api(&lua).map_err(|e| {
274 PluginError::InitializationFailed(format!("Failed to set up Lua API: {e}"))
275 })?;
276
277 lua.load(source)
279 .set_name(path.to_string_lossy())
280 .exec()
281 .map_err(|e| {
282 PluginError::InitializationFailed(format!(
283 "Failed to execute {}: {e}",
284 path.display()
285 ))
286 })?;
287
288 let (name, version, description) = Self::extract_metadata(&lua).map_err(|e| {
290 PluginError::InvalidConfiguration(format!(
291 "Failed to read plugin metadata from {}: {e}",
292 path.display()
293 ))
294 })?;
295
296 let commands = Self::extract_commands(&lua).map_err(|e| {
298 PluginError::InvalidConfiguration(format!(
299 "Failed to read commands from {}: {e}",
300 path.display()
301 ))
302 })?;
303
304 let keybindings = Self::extract_keybindings(&lua).map_err(|e| {
306 PluginError::InvalidConfiguration(format!(
307 "Failed to read keybindings from {}: {e}",
308 path.display()
309 ))
310 })?;
311
312 let (on_activate_key, on_deactivate_key) = Self::extract_lifecycle_hooks(&lua);
314
315 let theme = Self::extract_theme(&lua);
317
318 let mut refresh_callbacks = FxHashMap::default();
320
321 let table_pane_types = Self::extract_table_pane_types(&lua, &name, &mut refresh_callbacks)
323 .map_err(|e| {
324 PluginError::InvalidConfiguration(format!(
325 "Failed to read table pane types from {}: {e}",
326 path.display()
327 ))
328 })?;
329
330 let chart_pane_types = Self::extract_chart_pane_types(&lua, &name, &mut refresh_callbacks)
332 .map_err(|e| {
333 PluginError::InvalidConfiguration(format!(
334 "Failed to read chart pane types from {}: {e}",
335 path.display()
336 ))
337 })?;
338
339 let stat_pane_types = Self::extract_stat_pane_types(&lua, &name, &mut refresh_callbacks)
341 .map_err(|e| {
342 PluginError::InvalidConfiguration(format!(
343 "Failed to read stat pane types from {}: {e}",
344 path.display()
345 ))
346 })?;
347
348 let gauge_pane_types = Self::extract_gauge_pane_types(&lua, &name, &mut refresh_callbacks)
350 .map_err(|e| {
351 PluginError::InvalidConfiguration(format!(
352 "Failed to read gauge pane types from {}: {e}",
353 path.display()
354 ))
355 })?;
356
357 Ok(Self {
358 lua: Mutex::new(lua),
359 name: Box::leak(name.into_boxed_str()),
360 version: Box::leak(version.into_boxed_str()),
361 description: Box::leak(description.into_boxed_str()),
362 commands,
363 keybindings,
364 table_pane_types,
365 chart_pane_types,
366 stat_pane_types,
367 gauge_pane_types,
368 refresh_callbacks,
369 theme,
370 path: path.to_path_buf(),
371 active: false,
372 on_activate_key,
373 on_deactivate_key,
374 })
375 }
376
377 fn setup_registration_api(lua: &Lua) -> LuaResult<()> {
379 let globals = lua.globals();
380
381 let enya = lua.create_table()?;
383
384 let registered_commands = lua.create_table()?;
386 let registered_keybindings = lua.create_table()?;
387 let registered_table_panes = lua.create_table()?;
388 let registered_chart_panes = lua.create_table()?;
389 let registered_stat_panes = lua.create_table()?;
390 let registered_gauge_panes = lua.create_table()?;
391
392 let commands_ref = registered_commands.clone();
394 let register_command = lua.create_function(
395 move |lua, (name, config, callback): (String, Table, Function)| {
396 let cmd_table = lua.create_table()?;
397 cmd_table.set("name", name)?;
398 cmd_table.set(
399 "description",
400 config
401 .get::<Option<String>>("description")?
402 .unwrap_or_default(),
403 )?;
404 cmd_table.set(
405 "aliases",
406 config
407 .get::<Option<Vec<String>>>("aliases")?
408 .unwrap_or_default(),
409 )?;
410 cmd_table.set(
411 "accepts_args",
412 config.get::<Option<bool>>("accepts_args")?.unwrap_or(false),
413 )?;
414 cmd_table.set("callback", callback)?;
415
416 let len = commands_ref.len()? + 1;
417 commands_ref.set(len, cmd_table)?;
418
419 Ok(())
420 },
421 )?;
422 enya.set("register_command", register_command)?;
423
424 let keybindings_ref = registered_keybindings.clone();
426 let keymap = lua.create_function(move |lua, args: Variadic<Value>| {
427 let args: Vec<Value> = args.into_iter().collect();
428 if args.len() < 2 {
429 return Err(mlua::Error::runtime(
430 "keymap requires at least 2 arguments: keys, command",
431 ));
432 }
433
434 let keys = match &args[0] {
435 Value::String(s) => s.to_str()?.to_string(),
436 _ => return Err(mlua::Error::runtime("keys must be a string")),
437 };
438
439 let command = match &args[1] {
440 Value::String(s) => s.to_str()?.to_string(),
441 _ => return Err(mlua::Error::runtime("command must be a string")),
442 };
443
444 let description = args
445 .get(2)
446 .and_then(|v| match v {
447 Value::String(s) => s.to_str().ok().map(|s| s.to_string()),
448 _ => None,
449 })
450 .unwrap_or_default();
451
452 let modes: Vec<String> = args
453 .get(3)
454 .and_then(|v| match v {
455 Value::Table(t) => {
456 let modes: Vec<String> = t
457 .clone()
458 .pairs::<i64, String>()
459 .flatten()
460 .map(|(_, mode)| mode)
461 .collect();
462 Some(modes)
463 }
464 _ => None,
465 })
466 .unwrap_or_default();
467
468 let kb_table = lua.create_table()?;
469 kb_table.set("keys", keys)?;
470 kb_table.set("command", command)?;
471 kb_table.set("description", description)?;
472 kb_table.set("modes", modes)?;
473
474 let len = keybindings_ref.len()? + 1;
475 keybindings_ref.set(len, kb_table)?;
476
477 Ok(())
478 })?;
479 enya.set("keymap", keymap)?;
480
481 let table_panes_ref = registered_table_panes.clone();
484 let register_table_pane = lua.create_function(move |lua, args: Variadic<Value>| {
485 let args: Vec<Value> = args.into_iter().collect();
486 if args.len() < 2 {
487 return Err(mlua::Error::runtime(
488 "register_table_pane requires at least 2 arguments: name, config",
489 ));
490 }
491
492 let name = match &args[0] {
493 Value::String(s) => s.to_str()?.to_string(),
494 _ => return Err(mlua::Error::runtime("name must be a string")),
495 };
496
497 let config = match &args[1] {
498 Value::Table(t) => t.clone(),
499 _ => return Err(mlua::Error::runtime("config must be a table")),
500 };
501
502 let pane_table = lua.create_table()?;
503 pane_table.set("name", name)?;
504 pane_table.set(
505 "title",
506 config
507 .get::<Option<String>>("title")?
508 .unwrap_or_else(|| "Custom Table".to_string()),
509 )?;
510 pane_table.set(
511 "refresh_interval",
512 config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
513 )?;
514
515 let columns_table = lua.create_table()?;
517 if let Ok(columns) = config.get::<Table>("columns") {
518 for (idx, col) in columns.pairs::<i64, Table>().flatten() {
519 let col_table = lua.create_table()?;
520 col_table.set("name", col.get::<String>("name")?)?;
521 col_table.set("key", col.get::<Option<String>>("key")?.unwrap_or_default())?;
522 col_table.set("width", col.get::<Option<f32>>("width")?.unwrap_or(0.0))?;
523 columns_table.set(idx, col_table)?;
524 }
525 }
526 pane_table.set("columns", columns_table)?;
527
528 if let Some(Value::Function(callback)) = args.get(2) {
530 pane_table.set("callback", callback.clone())?;
531 }
532
533 let len = table_panes_ref.len()? + 1;
534 table_panes_ref.set(len, pane_table)?;
535
536 Ok(())
537 })?;
538 enya.set("register_table_pane", register_table_pane)?;
539
540 let chart_panes_ref = registered_chart_panes.clone();
542 let register_chart_pane = lua.create_function(move |lua, args: Variadic<Value>| {
543 let args: Vec<Value> = args.into_iter().collect();
544 if args.len() < 2 {
545 return Err(mlua::Error::runtime(
546 "register_chart_pane requires at least 2 arguments: name, config",
547 ));
548 }
549
550 let name = match &args[0] {
551 Value::String(s) => s.to_str()?.to_string(),
552 _ => return Err(mlua::Error::runtime("name must be a string")),
553 };
554
555 let config = match &args[1] {
556 Value::Table(t) => t.clone(),
557 _ => return Err(mlua::Error::runtime("config must be a table")),
558 };
559
560 let pane_table = lua.create_table()?;
561 pane_table.set("name", name)?;
562 pane_table.set(
563 "title",
564 config
565 .get::<Option<String>>("title")?
566 .unwrap_or_else(|| "Custom Chart".to_string()),
567 )?;
568 pane_table.set(
569 "y_unit",
570 config.get::<Option<String>>("y_unit")?.unwrap_or_default(),
571 )?;
572 pane_table.set(
573 "refresh_interval",
574 config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
575 )?;
576
577 if let Some(Value::Function(callback)) = args.get(2) {
579 pane_table.set("callback", callback.clone())?;
580 }
581
582 let len = chart_panes_ref.len()? + 1;
583 chart_panes_ref.set(len, pane_table)?;
584
585 Ok(())
586 })?;
587 enya.set("register_chart_pane", register_chart_pane)?;
588
589 let stat_panes_ref = registered_stat_panes.clone();
591 let register_stat_pane = lua.create_function(move |lua, args: Variadic<Value>| {
592 let args: Vec<Value> = args.into_iter().collect();
593 if args.len() < 2 {
594 return Err(mlua::Error::runtime(
595 "register_stat_pane requires at least 2 arguments: name, config",
596 ));
597 }
598
599 let name = match &args[0] {
600 Value::String(s) => s.to_str()?.to_string(),
601 _ => return Err(mlua::Error::runtime("name must be a string")),
602 };
603
604 let config = match &args[1] {
605 Value::Table(t) => t.clone(),
606 _ => return Err(mlua::Error::runtime("config must be a table")),
607 };
608
609 let pane_table = lua.create_table()?;
610 pane_table.set("name", name)?;
611 pane_table.set(
612 "title",
613 config
614 .get::<Option<String>>("title")?
615 .unwrap_or_else(|| "Custom Stat".to_string()),
616 )?;
617 pane_table.set(
618 "unit",
619 config.get::<Option<String>>("unit")?.unwrap_or_default(),
620 )?;
621 pane_table.set(
622 "refresh_interval",
623 config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
624 )?;
625
626 if let Some(Value::Function(callback)) = args.get(2) {
628 pane_table.set("callback", callback.clone())?;
629 }
630
631 let len = stat_panes_ref.len()? + 1;
632 stat_panes_ref.set(len, pane_table)?;
633
634 Ok(())
635 })?;
636 enya.set("register_stat_pane", register_stat_pane)?;
637
638 let gauge_panes_ref = registered_gauge_panes.clone();
640 let register_gauge_pane = lua.create_function(move |lua, args: Variadic<Value>| {
641 let args: Vec<Value> = args.into_iter().collect();
642 if args.len() < 2 {
643 return Err(mlua::Error::runtime(
644 "register_gauge_pane requires at least 2 arguments: name, config",
645 ));
646 }
647
648 let name = match &args[0] {
649 Value::String(s) => s.to_str()?.to_string(),
650 _ => return Err(mlua::Error::runtime("name must be a string")),
651 };
652
653 let config = match &args[1] {
654 Value::Table(t) => t.clone(),
655 _ => return Err(mlua::Error::runtime("config must be a table")),
656 };
657
658 let pane_table = lua.create_table()?;
659 pane_table.set("name", name)?;
660 pane_table.set(
661 "title",
662 config
663 .get::<Option<String>>("title")?
664 .unwrap_or_else(|| "Custom Gauge".to_string()),
665 )?;
666 pane_table.set(
667 "unit",
668 config.get::<Option<String>>("unit")?.unwrap_or_default(),
669 )?;
670 pane_table.set("min", config.get::<Option<f64>>("min")?.unwrap_or(0.0))?;
671 pane_table.set("max", config.get::<Option<f64>>("max")?.unwrap_or(100.0))?;
672 pane_table.set(
673 "refresh_interval",
674 config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
675 )?;
676
677 if let Some(Value::Function(callback)) = args.get(2) {
679 pane_table.set("callback", callback.clone())?;
680 }
681
682 let len = gauge_panes_ref.len()? + 1;
683 gauge_panes_ref.set(len, pane_table)?;
684
685 Ok(())
686 })?;
687 enya.set("register_gauge_pane", register_gauge_pane)?;
688
689 enya.set("_registered_commands", registered_commands)?;
691 enya.set("_registered_keybindings", registered_keybindings)?;
692 enya.set("_registered_table_panes", registered_table_panes)?;
693 enya.set("_registered_chart_panes", registered_chart_panes)?;
694 enya.set("_registered_stat_panes", registered_stat_panes)?;
695 enya.set("_registered_gauge_panes", registered_gauge_panes)?;
696
697 setup_noop_api(lua, &enya)?;
702
703 globals.set("enya", enya)?;
704
705 Ok(())
706 }
707
708 fn extract_metadata(lua: &Lua) -> LuaResult<(String, String, String)> {
710 let globals = lua.globals();
711
712 let plugin: Table = globals.get("plugin")?;
714
715 let name: String = plugin.get("name")?;
716 let version: String = plugin
717 .get::<Option<String>>("version")?
718 .unwrap_or_else(|| "0.1.0".to_string());
719 let description: String = plugin
720 .get::<Option<String>>("description")?
721 .unwrap_or_default();
722
723 Ok((name, version, description))
724 }
725
726 fn extract_commands(lua: &Lua) -> LuaResult<Vec<LuaCommand>> {
728 let globals = lua.globals();
729 let enya: Table = globals.get("enya")?;
730 let registered: Table = enya.get("_registered_commands")?;
731
732 let mut commands = Vec::new();
733
734 for pair in registered.pairs::<i64, Table>() {
735 let (_, cmd_table) = pair?;
736
737 let name: String = cmd_table.get("name")?;
738 let description: String = cmd_table.get("description")?;
739 let aliases: Vec<String> = cmd_table.get("aliases")?;
740 let accepts_args: bool = cmd_table.get("accepts_args")?;
741 let callback: Function = cmd_table.get("callback")?;
742
743 let callback_key = lua.create_registry_value(callback)?;
745
746 commands.push(LuaCommand {
747 name,
748 description,
749 aliases,
750 accepts_args,
751 callback_key,
752 });
753 }
754
755 Ok(commands)
756 }
757
758 fn extract_keybindings(lua: &Lua) -> LuaResult<Vec<KeybindingConfig>> {
760 let globals = lua.globals();
761 let enya: Table = globals.get("enya")?;
762 let registered: Table = enya.get("_registered_keybindings")?;
763
764 let mut keybindings = Vec::new();
765
766 for pair in registered.pairs::<i64, Table>() {
767 let (_, kb_table) = pair?;
768
769 keybindings.push(KeybindingConfig {
770 keys: kb_table.get("keys")?,
771 command: kb_table.get("command")?,
772 description: kb_table.get("description")?,
773 modes: kb_table.get("modes")?,
774 });
775 }
776
777 Ok(keybindings)
778 }
779
780 fn extract_table_pane_types(
783 lua: &Lua,
784 plugin_name: &str,
785 refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
786 ) -> LuaResult<Vec<CustomTableConfig>> {
787 let globals = lua.globals();
788 let enya: Table = globals.get("enya")?;
789 let registered: Table = enya.get("_registered_table_panes")?;
790
791 let mut pane_types = Vec::new();
792
793 for pair in registered.pairs::<i64, Table>() {
794 let (_, pane_table) = pair?;
795
796 let name: String = pane_table.get("name")?;
797 let title: String = pane_table.get("title")?;
798 let refresh_interval: u32 = pane_table.get("refresh_interval")?;
799
800 let columns_table: Table = pane_table.get("columns")?;
802 let mut columns = Vec::new();
803
804 for col_pair in columns_table.pairs::<i64, Table>() {
805 let (_, col) = col_pair?;
806 let col_name: String = col.get("name")?;
807 let col_key: String = col.get("key")?;
808 let col_width: f32 = col.get("width")?;
809
810 let mut column = TableColumnConfig::new(col_name);
811 if !col_key.is_empty() {
812 column = column.with_key(col_key);
813 }
814 if col_width > 0.0 {
815 let width = col_width.min(u32::MAX as f32) as u32;
817 column = column.with_width(width);
818 }
819 columns.push(column);
820 }
821
822 if let Ok(callback) = pane_table.get::<Function>("callback") {
824 let callback_key = lua.create_registry_value(callback)?;
825 refresh_callbacks.insert(name.clone(), callback_key);
826 }
827
828 pane_types.push(CustomTableConfig {
829 name,
830 title,
831 columns,
832 refresh_interval,
833 plugin_name: plugin_name.to_string(),
834 });
835 }
836
837 Ok(pane_types)
838 }
839
840 fn extract_chart_pane_types(
843 lua: &Lua,
844 plugin_name: &str,
845 refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
846 ) -> LuaResult<Vec<CustomChartConfig>> {
847 let globals = lua.globals();
848 let enya: Table = globals.get("enya")?;
849 let registered: Table = enya.get("_registered_chart_panes")?;
850
851 let mut pane_types = Vec::new();
852
853 for pair in registered.pairs::<i64, Table>() {
854 let (_, pane_table) = pair?;
855
856 let name: String = pane_table.get("name")?;
857 let title: String = pane_table.get("title")?;
858 let y_unit: String = pane_table.get("y_unit")?;
859 let refresh_interval: u32 = pane_table.get("refresh_interval")?;
860
861 if let Ok(callback) = pane_table.get::<Function>("callback") {
863 let callback_key = lua.create_registry_value(callback)?;
864 refresh_callbacks.insert(name.clone(), callback_key);
865 }
866
867 pane_types.push(CustomChartConfig {
868 name,
869 title,
870 y_unit: if y_unit.is_empty() {
871 None
872 } else {
873 Some(y_unit)
874 },
875 refresh_interval,
876 plugin_name: plugin_name.to_string(),
877 });
878 }
879
880 Ok(pane_types)
881 }
882
883 fn extract_stat_pane_types(
886 lua: &Lua,
887 plugin_name: &str,
888 refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
889 ) -> LuaResult<Vec<StatPaneConfig>> {
890 let globals = lua.globals();
891 let enya: Table = globals.get("enya")?;
892 let registered: Table = enya.get("_registered_stat_panes")?;
893
894 let mut pane_types = Vec::new();
895
896 for pair in registered.pairs::<i64, Table>() {
897 let (_, pane_table) = pair?;
898
899 let name: String = pane_table.get("name")?;
900 let title: String = pane_table.get("title")?;
901 let unit: String = pane_table.get("unit")?;
902 let refresh_interval: u32 = pane_table.get("refresh_interval")?;
903
904 if let Ok(callback) = pane_table.get::<Function>("callback") {
906 let callback_key = lua.create_registry_value(callback)?;
907 refresh_callbacks.insert(name.clone(), callback_key);
908 }
909
910 pane_types.push(StatPaneConfig {
911 name,
912 title,
913 unit: if unit.is_empty() { None } else { Some(unit) },
914 refresh_interval,
915 plugin_name: plugin_name.to_string(),
916 });
917 }
918
919 Ok(pane_types)
920 }
921
922 fn extract_gauge_pane_types(
925 lua: &Lua,
926 plugin_name: &str,
927 refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
928 ) -> LuaResult<Vec<GaugePaneConfig>> {
929 let globals = lua.globals();
930 let enya: Table = globals.get("enya")?;
931 let registered: Table = enya.get("_registered_gauge_panes")?;
932
933 let mut pane_types = Vec::new();
934
935 for pair in registered.pairs::<i64, Table>() {
936 let (_, pane_table) = pair?;
937
938 let name: String = pane_table.get("name")?;
939 let title: String = pane_table.get("title")?;
940 let unit: String = pane_table.get("unit")?;
941 let min: f64 = pane_table.get("min")?;
942 let max: f64 = pane_table.get("max")?;
943 let refresh_interval: u32 = pane_table.get("refresh_interval")?;
944
945 if let Ok(callback) = pane_table.get::<Function>("callback") {
947 let callback_key = lua.create_registry_value(callback)?;
948 refresh_callbacks.insert(name.clone(), callback_key);
949 }
950
951 pane_types.push(GaugePaneConfig {
952 name,
953 title,
954 unit: if unit.is_empty() { None } else { Some(unit) },
955 min_scaled: (min * 1_000_000.0) as i64,
956 max_scaled: (max * 1_000_000.0) as i64,
957 refresh_interval,
958 plugin_name: plugin_name.to_string(),
959 });
960 }
961
962 Ok(pane_types)
963 }
964
965 fn extract_lifecycle_hooks(lua: &Lua) -> (Option<RegistryKey>, Option<RegistryKey>) {
967 let globals = lua.globals();
968
969 let on_activate_key = globals
970 .get::<Function>("on_activate")
971 .ok()
972 .and_then(|f| lua.create_registry_value(f).ok());
973
974 let on_deactivate_key = globals
975 .get::<Function>("on_deactivate")
976 .ok()
977 .and_then(|f| lua.create_registry_value(f).ok());
978
979 (on_activate_key, on_deactivate_key)
980 }
981
982 fn extract_theme(lua: &Lua) -> Option<ThemeDefinition> {
984 let globals = lua.globals();
985
986 let theme_table: Table = globals.get("theme").ok()?;
988
989 let name: String = theme_table.get("name").ok()?;
991
992 let display_name: String = theme_table
994 .get::<Option<String>>("display_name")
995 .ok()
996 .flatten()
997 .unwrap_or_else(|| name.clone());
998
999 let base_str: String = theme_table
1000 .get::<Option<String>>("base")
1001 .ok()
1002 .flatten()
1003 .unwrap_or_else(|| "dark".to_string());
1004 let base = ThemeBase::parse(&base_str);
1005
1006 let colors = Self::extract_theme_colors(lua, &theme_table);
1008
1009 Some(ThemeDefinition {
1010 name,
1011 display_name,
1012 base,
1013 colors,
1014 })
1015 }
1016
1017 fn extract_theme_colors(_lua: &Lua, theme_table: &Table) -> ThemeColors {
1019 let colors_table: Option<Table> = theme_table.get("colors").ok();
1020 let mut colors = ThemeColors::default();
1021
1022 let Some(ct) = colors_table else {
1023 return colors;
1024 };
1025
1026 let parse_color = |key: &str| -> Option<u32> {
1028 ct.get::<Option<String>>(key)
1029 .ok()
1030 .flatten()
1031 .and_then(|s| ThemeColors::parse_hex(&s))
1032 };
1033
1034 colors.bg_base = parse_color("bg_base");
1036 colors.bg_surface = parse_color("bg_surface");
1037 colors.bg_elevated = parse_color("bg_elevated");
1038
1039 colors.text_primary = parse_color("text_primary");
1041 colors.text_secondary = parse_color("text_secondary");
1042 colors.text_muted = parse_color("text_muted");
1043
1044 colors.accent_primary = parse_color("accent_primary");
1046 colors.accent_hover = parse_color("accent_hover");
1047 colors.accent_muted = parse_color("accent_muted");
1048
1049 colors.border_subtle = parse_color("border_subtle");
1051 colors.border_strong = parse_color("border_strong");
1052
1053 colors.success = parse_color("success");
1055 colors.warning = parse_color("warning");
1056 colors.error = parse_color("error");
1057 colors.info = parse_color("info");
1058
1059 if let Ok(Some(chart_table)) = ct.get::<Option<Table>>("chart") {
1061 let mut palette = Vec::new();
1062 for (_, hex) in chart_table.pairs::<i64, String>().flatten() {
1063 if let Some(color) = ThemeColors::parse_hex(&hex) {
1064 palette.push(color);
1065 }
1066 }
1067 colors.chart_palette = palette;
1068 }
1069
1070 colors
1071 }
1072
1073 fn setup_runtime_api<'lua, 'scope>(
1075 lua: &'lua Lua,
1076 scope: &'lua mlua::Scope<'lua, 'scope>,
1077 ctx: &'scope PluginContext,
1078 ) -> LuaResult<()>
1079 where
1080 'scope: 'lua,
1081 {
1082 let globals = lua.globals();
1083 let enya: Table = globals.get("enya")?;
1084
1085 let notify_fn = scope.create_function(|_, (level, msg): (String, String)| {
1087 ctx.notify(&level, &msg);
1088 Ok(())
1089 })?;
1090 enya.set("notify", notify_fn)?;
1091
1092 let log_fn = scope.create_function(|_, (level, msg): (String, String)| {
1094 let log_level = LogLevel::parse(&level);
1095 ctx.log(log_level, &msg);
1096 Ok(())
1097 })?;
1098 enya.set("log", log_fn)?;
1099
1100 let repaint_fn = scope.create_function(|_, ()| {
1102 ctx.request_repaint();
1103 Ok(())
1104 })?;
1105 enya.set("request_repaint", repaint_fn)?;
1106
1107 let version = ctx.editor_version();
1109 let version_fn = scope.create_function(move |_, ()| Ok(version.to_string()))?;
1110 enya.set("editor_version", version_fn)?;
1111
1112 let is_wasm = ctx.is_wasm();
1114 let wasm_fn = scope.create_function(move |_, ()| Ok(is_wasm))?;
1115 enya.set("is_wasm", wasm_fn)?;
1116
1117 let theme_name = ctx.theme_name();
1119 let theme_fn = scope.create_function(move |_, ()| Ok(theme_name.to_string()))?;
1120 enya.set("theme_name", theme_fn)?;
1121
1122 let clipboard_write_fn =
1124 scope.create_function(|_, text: String| Ok(ctx.clipboard_write(&text)))?;
1125 enya.set("clipboard_write", clipboard_write_fn)?;
1126
1127 let clipboard_read_fn = scope.create_function(|_, ()| Ok(ctx.clipboard_read()))?;
1129 enya.set("clipboard_read", clipboard_read_fn)?;
1130
1131 let execute_fn = scope.create_function(|_, (cmd, args): (String, Option<String>)| {
1135 let args_str = args.as_deref().unwrap_or("");
1136 log::info!("[lua] Execute request: {cmd} {args_str}");
1137 Ok(true)
1140 })?;
1141 enya.set("execute", execute_fn)?;
1142
1143 let http_get_fn =
1146 scope.create_function(|lua, (url, headers): (String, Option<Table>)| {
1147 use rustc_hash::FxHashMap;
1148
1149 let mut header_map = FxHashMap::default();
1150 if let Some(h) = headers {
1151 for pair in h.pairs::<String, String>() {
1152 let (k, v) = pair?;
1153 header_map.insert(k, v);
1154 }
1155 }
1156
1157 let result = ctx.http_get(&url, &header_map);
1158 let response_table = lua.create_table()?;
1159
1160 match result {
1161 Ok(resp) => {
1162 response_table.set("status", resp.status)?;
1163 response_table.set("body", resp.body)?;
1164 let headers_table = lua.create_table()?;
1165 for (k, v) in resp.headers {
1166 headers_table.set(k, v)?;
1167 }
1168 response_table.set("headers", headers_table)?;
1169 }
1170 Err(e) => {
1171 response_table.set("error", e.message)?;
1172 }
1173 }
1174
1175 Ok(response_table)
1176 })?;
1177 enya.set("http_get", http_get_fn)?;
1178
1179 let http_post_fn = scope.create_function(
1182 |lua, (url, body, headers): (String, String, Option<Table>)| {
1183 use rustc_hash::FxHashMap;
1184
1185 let mut header_map = FxHashMap::default();
1186 if let Some(h) = headers {
1187 for pair in h.pairs::<String, String>() {
1188 let (k, v) = pair?;
1189 header_map.insert(k, v);
1190 }
1191 }
1192
1193 let result = ctx.http_post(&url, &body, &header_map);
1194 let response_table = lua.create_table()?;
1195
1196 match result {
1197 Ok(resp) => {
1198 response_table.set("status", resp.status)?;
1199 response_table.set("body", resp.body)?;
1200 let headers_table = lua.create_table()?;
1201 for (k, v) in resp.headers {
1202 headers_table.set(k, v)?;
1203 }
1204 response_table.set("headers", headers_table)?;
1205 }
1206 Err(e) => {
1207 response_table.set("error", e.message)?;
1208 }
1209 }
1210
1211 Ok(response_table)
1212 },
1213 )?;
1214 enya.set("http_post", http_post_fn)?;
1215
1216 let add_query_pane_fn =
1220 scope.create_function(|_, (query, title): (String, Option<String>)| {
1221 ctx.add_query_pane(&query, title.as_deref());
1222 Ok(())
1223 })?;
1224 enya.set("add_query_pane", add_query_pane_fn)?;
1225
1226 let add_logs_pane_fn = scope.create_function(|_, ()| {
1228 ctx.add_logs_pane();
1229 Ok(())
1230 })?;
1231 enya.set("add_logs_pane", add_logs_pane_fn)?;
1232
1233 let add_tracing_pane_fn = scope.create_function(|_, trace_id: Option<String>| {
1235 ctx.add_tracing_pane(trace_id.as_deref());
1236 Ok(())
1237 })?;
1238 enya.set("add_tracing_pane", add_tracing_pane_fn)?;
1239
1240 let add_terminal_pane_fn = scope.create_function(|_, ()| {
1242 ctx.add_terminal_pane();
1243 Ok(())
1244 })?;
1245 enya.set("add_terminal_pane", add_terminal_pane_fn)?;
1246
1247 let add_sql_pane_fn = scope.create_function(|_, ()| {
1249 ctx.add_sql_pane();
1250 Ok(())
1251 })?;
1252 enya.set("add_sql_pane", add_sql_pane_fn)?;
1253
1254 let close_pane_fn = scope.create_function(|_, ()| {
1256 ctx.close_focused_pane();
1257 Ok(())
1258 })?;
1259 enya.set("close_pane", close_pane_fn)?;
1260
1261 let focus_pane_fn = scope.create_function(|_, direction: String| {
1263 ctx.focus_pane(&direction);
1264 Ok(())
1265 })?;
1266 enya.set("focus_pane", focus_pane_fn)?;
1267
1268 let set_time_range_fn = scope.create_function(|_, preset: String| {
1272 ctx.set_time_range_preset(&preset);
1273 Ok(())
1274 })?;
1275 enya.set("set_time_range", set_time_range_fn)?;
1276
1277 let set_time_range_absolute_fn = scope.create_function(|_, (start, end): (f64, f64)| {
1279 ctx.set_time_range_absolute(start, end);
1280 Ok(())
1281 })?;
1282 enya.set("set_time_range_absolute", set_time_range_absolute_fn)?;
1283
1284 let get_time_range_fn = scope.create_function(|lua, ()| {
1286 let (start, end) = ctx.get_time_range();
1287 let table = lua.create_table()?;
1288 table.set("start", start)?;
1289 table.set("end", end)?;
1290 Ok(table)
1291 })?;
1292 enya.set("get_time_range", get_time_range_fn)?;
1293
1294 let get_focused_pane_fn = scope.create_function(|lua, ()| {
1297 if let Some(info) = ctx.get_focused_pane_info() {
1298 let table = lua.create_table()?;
1299 table.set("pane_type", info.pane_type)?;
1300 if let Some(title) = info.title {
1301 table.set("title", title)?;
1302 }
1303 if let Some(query) = info.query {
1304 table.set("query", query)?;
1305 }
1306 if let Some(metric_name) = info.metric_name {
1307 table.set("metric_name", metric_name)?;
1308 }
1309 Ok(mlua::Value::Table(table))
1310 } else {
1311 Ok(mlua::Value::Nil)
1312 }
1313 })?;
1314 enya.set("get_focused_pane", get_focused_pane_fn)?;
1315
1316 let add_custom_pane_fn = scope.create_function(|_, pane_type: String| {
1320 ctx.add_custom_table_pane(&pane_type);
1321 Ok(())
1322 })?;
1323 enya.set("add_custom_pane", add_custom_pane_fn)?;
1324
1325 let set_pane_data_fn = scope.create_function(|_, (pane_type, data): (String, Table)| {
1328 use crate::{CustomTableData, CustomTableRow};
1329
1330 if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1332 let table_data = CustomTableData::with_error(error);
1333 ctx.update_custom_table_data_by_type(&pane_type, table_data);
1334 return Ok(());
1335 }
1336
1337 let mut rows = Vec::new();
1339 if let Ok(rows_table) = data.get::<Table>("rows") {
1340 for (_, row_table) in rows_table.pairs::<i64, Table>().flatten() {
1341 let mut row = CustomTableRow::new();
1342 for (key, value) in row_table.pairs::<String, String>().flatten() {
1343 row = row.with_cell(key, value);
1344 }
1345 rows.push(row);
1346 }
1347 }
1348
1349 let table_data = CustomTableData::with_rows(rows);
1350 ctx.update_custom_table_data_by_type(&pane_type, table_data);
1351 Ok(())
1352 })?;
1353 enya.set("set_pane_data", set_pane_data_fn)?;
1354
1355 let add_chart_pane_fn = scope.create_function(|_, pane_type: String| {
1359 ctx.add_custom_chart_pane(&pane_type);
1360 Ok(())
1361 })?;
1362 enya.set("add_chart_pane", add_chart_pane_fn)?;
1363
1364 let set_chart_data_fn =
1372 scope.create_function(|_, (pane_type, data): (String, Table)| {
1373 use crate::{ChartDataPoint, ChartSeries, CustomChartData};
1374
1375 if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1377 let chart_data = CustomChartData::with_error(error);
1378 ctx.update_custom_chart_data_by_type(&pane_type, chart_data);
1379 return Ok(());
1380 }
1381
1382 let mut series_vec = Vec::new();
1384 if let Ok(series_table) = data.get::<Table>("series") {
1385 for (_, series_entry) in series_table.pairs::<i64, Table>().flatten() {
1386 let name: String = series_entry
1387 .get::<Option<String>>("name")?
1388 .unwrap_or_else(|| "unnamed".to_string());
1389
1390 let mut series = ChartSeries::new(name);
1391
1392 if let Ok(tags_table) = series_entry.get::<Table>("tags") {
1394 for (key, value) in tags_table.pairs::<String, String>().flatten() {
1395 series = series.with_tag(key, value);
1396 }
1397 }
1398
1399 if let Ok(points_table) = series_entry.get::<Table>("points") {
1401 for (_, point_entry) in points_table.pairs::<i64, Table>().flatten() {
1402 let timestamp: f64 = point_entry.get("timestamp")?;
1403 let value: f64 = point_entry.get("value")?;
1404 series.points.push(ChartDataPoint::new(timestamp, value));
1405 }
1406 }
1407
1408 series_vec.push(series);
1409 }
1410 }
1411
1412 let chart_data = CustomChartData::with_series(series_vec);
1413 ctx.update_custom_chart_data_by_type(&pane_type, chart_data);
1414 Ok(())
1415 })?;
1416 enya.set("set_chart_data", set_chart_data_fn)?;
1417
1418 let add_stat_pane_fn = scope.create_function(|_, pane_type: String| {
1422 ctx.add_stat_pane(&pane_type);
1423 Ok(())
1424 })?;
1425 enya.set("add_stat_pane", add_stat_pane_fn)?;
1426
1427 let set_stat_data_fn = scope.create_function(|_, (pane_type, data): (String, Table)| {
1439 use crate::{StatPaneData, ThresholdConfig};
1440
1441 if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1443 let stat_data = StatPaneData::with_error(error);
1444 ctx.update_stat_data_by_type(&pane_type, stat_data);
1445 return Ok(());
1446 }
1447
1448 let value: f64 = data.get::<Option<f64>>("value")?.unwrap_or(0.0);
1449 let mut stat_data = StatPaneData::with_value(value);
1450
1451 if let Ok(sparkline_table) = data.get::<Table>("sparkline") {
1453 let mut sparkline = Vec::new();
1454 for (_, val) in sparkline_table.pairs::<i64, f64>().flatten() {
1455 sparkline.push(val);
1456 }
1457 stat_data.sparkline = sparkline;
1458 }
1459
1460 if let Ok(Some(change_value)) = data.get::<Option<f64>>("change_value") {
1462 let change_period: String = data
1463 .get::<Option<String>>("change_period")?
1464 .unwrap_or_else(|| "vs last period".to_string());
1465 stat_data.change_value = Some(change_value);
1466 stat_data.change_period = Some(change_period);
1467 }
1468
1469 if let Ok(thresholds_table) = data.get::<Table>("thresholds") {
1471 for (_, thresh) in thresholds_table.pairs::<i64, Table>().flatten() {
1472 let value: f64 = thresh.get("value")?;
1473 let color: String = thresh.get("color")?;
1474 let label: Option<String> = thresh.get::<Option<String>>("label")?;
1475 let mut threshold = ThresholdConfig::new(value, color);
1476 if let Some(lbl) = label {
1477 threshold = threshold.with_label(lbl);
1478 }
1479 stat_data.thresholds.push(threshold);
1480 }
1481 }
1482
1483 ctx.update_stat_data_by_type(&pane_type, stat_data);
1484 Ok(())
1485 })?;
1486 enya.set("set_stat_data", set_stat_data_fn)?;
1487
1488 let add_gauge_pane_fn = scope.create_function(|_, pane_type: String| {
1492 ctx.add_gauge_pane(&pane_type);
1493 Ok(())
1494 })?;
1495 enya.set("add_gauge_pane", add_gauge_pane_fn)?;
1496
1497 let set_gauge_data_fn =
1506 scope.create_function(|_, (pane_type, data): (String, Table)| {
1507 use crate::{GaugePaneData, ThresholdConfig};
1508
1509 if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1511 let gauge_data = GaugePaneData::with_error(error);
1512 ctx.update_gauge_data_by_type(&pane_type, gauge_data);
1513 return Ok(());
1514 }
1515
1516 let value: f64 = data.get::<Option<f64>>("value")?.unwrap_or(0.0);
1517 let mut gauge_data = GaugePaneData::with_value(value);
1518
1519 if let Ok(thresholds_table) = data.get::<Table>("thresholds") {
1521 for (_, thresh) in thresholds_table.pairs::<i64, Table>().flatten() {
1522 let value: f64 = thresh.get("value")?;
1523 let color: String = thresh.get("color")?;
1524 let label: Option<String> = thresh.get::<Option<String>>("label")?;
1525 let mut threshold = ThresholdConfig::new(value, color);
1526 if let Some(lbl) = label {
1527 threshold = threshold.with_label(lbl);
1528 }
1529 gauge_data.thresholds.push(threshold);
1530 }
1531 }
1532
1533 ctx.update_gauge_data_by_type(&pane_type, gauge_data);
1534 Ok(())
1535 })?;
1536 enya.set("set_gauge_data", set_gauge_data_fn)?;
1537
1538 Ok(())
1539 }
1540
1541 fn call_lifecycle_hook(
1543 &self,
1544 hook_key: &Option<RegistryKey>,
1545 ctx: &PluginContext,
1546 ) -> PluginResult<()> {
1547 let Some(key) = hook_key else {
1548 return Ok(());
1549 };
1550
1551 let lua = self.lua.lock();
1552
1553 lua.scope(|scope| {
1554 Self::setup_runtime_api(&lua, scope, ctx)?;
1555
1556 let hook: Function = lua.registry_value(key)?;
1557 hook.call::<()>(())?;
1558
1559 Ok(())
1560 })
1561 .map_err(|e| PluginError::OperationFailed(format!("Lifecycle hook failed: {e}")))
1562 }
1563}
1564
1565impl Plugin for LuaPlugin {
1566 fn name(&self) -> &'static str {
1567 self.name
1568 }
1569
1570 fn version(&self) -> &'static str {
1571 self.version
1572 }
1573
1574 fn description(&self) -> &'static str {
1575 self.description
1576 }
1577
1578 fn capabilities(&self) -> PluginCapabilities {
1579 let mut caps = PluginCapabilities::empty();
1580 if !self.commands.is_empty() {
1581 caps |= PluginCapabilities::COMMANDS;
1582 }
1583 if !self.keybindings.is_empty() {
1584 caps |= PluginCapabilities::KEYBOARD;
1585 }
1586 if self.theme.is_some() {
1587 caps |= PluginCapabilities::CUSTOM_THEMES;
1588 }
1589 if !self.table_pane_types.is_empty()
1590 || !self.chart_pane_types.is_empty()
1591 || !self.stat_pane_types.is_empty()
1592 || !self.gauge_pane_types.is_empty()
1593 {
1594 caps |= PluginCapabilities::PANES;
1595 }
1596 caps
1597 }
1598
1599 fn init(&mut self, _ctx: &PluginContext) -> PluginResult<()> {
1600 log::info!(
1601 "[plugin:{}] Lua plugin loaded from {}",
1602 self.name,
1603 self.path.display()
1604 );
1605 Ok(())
1606 }
1607
1608 fn activate(&mut self, ctx: &PluginContext) -> PluginResult<()> {
1609 self.active = true;
1610
1611 if self.on_activate_key.is_some() {
1613 self.call_lifecycle_hook(&self.on_activate_key, ctx)?;
1614 }
1615
1616 log::info!("[plugin:{}] Activated", self.name);
1617 Ok(())
1618 }
1619
1620 fn deactivate(&mut self, ctx: &PluginContext) -> PluginResult<()> {
1621 if self.on_deactivate_key.is_some() {
1623 self.call_lifecycle_hook(&self.on_deactivate_key, ctx)?;
1624 }
1625
1626 self.active = false;
1627 log::info!("[plugin:{}] Deactivated", self.name);
1628 Ok(())
1629 }
1630
1631 fn commands(&self) -> Vec<CommandConfig> {
1632 self.commands
1633 .iter()
1634 .map(|c| CommandConfig {
1635 name: c.name.clone(),
1636 aliases: c.aliases.clone(),
1637 description: c.description.clone(),
1638 accepts_args: c.accepts_args,
1639 })
1640 .collect()
1641 }
1642
1643 fn keybindings(&self) -> Vec<KeybindingConfig> {
1644 self.keybindings.clone()
1645 }
1646
1647 fn themes(&self) -> Vec<ThemeDefinition> {
1648 self.theme.clone().into_iter().collect()
1649 }
1650
1651 fn custom_table_panes(&self) -> Vec<crate::CustomTableConfig> {
1652 self.table_pane_types.clone()
1653 }
1654
1655 fn custom_chart_panes(&self) -> Vec<crate::CustomChartConfig> {
1656 self.chart_pane_types.clone()
1657 }
1658
1659 fn custom_stat_panes(&self) -> Vec<crate::StatPaneConfig> {
1660 self.stat_pane_types.clone()
1661 }
1662
1663 fn custom_gauge_panes(&self) -> Vec<crate::GaugePaneConfig> {
1664 self.gauge_pane_types.clone()
1665 }
1666
1667 fn refreshable_pane_types(&self) -> Vec<(&str, u32)> {
1668 let mut result = Vec::new();
1669
1670 for config in &self.table_pane_types {
1672 if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1673 result.push((config.name.as_str(), config.refresh_interval));
1674 }
1675 }
1676 for config in &self.chart_pane_types {
1677 if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1678 result.push((config.name.as_str(), config.refresh_interval));
1679 }
1680 }
1681 for config in &self.stat_pane_types {
1682 if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1683 result.push((config.name.as_str(), config.refresh_interval));
1684 }
1685 }
1686 for config in &self.gauge_pane_types {
1687 if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1688 result.push((config.name.as_str(), config.refresh_interval));
1689 }
1690 }
1691
1692 result
1693 }
1694
1695 fn trigger_pane_refresh(&mut self, pane_type: &str, ctx: &PluginContext) -> bool {
1696 let Some(callback_key) = self.refresh_callbacks.get(pane_type) else {
1697 log::warn!(
1698 "[plugin:{}] No refresh callback for pane type '{}'",
1699 self.name,
1700 pane_type
1701 );
1702 return false;
1703 };
1704
1705 let lua = self.lua.lock();
1706
1707 let result = lua.scope(|scope| {
1708 Self::setup_runtime_api(&lua, scope, ctx)?;
1710
1711 let callback: Function = lua.registry_value(callback_key)?;
1713
1714 callback.call::<()>(())?;
1716
1717 Ok(())
1718 });
1719
1720 match result {
1721 Ok(()) => {
1722 log::debug!("[plugin:{}] Refreshed pane type '{}'", self.name, pane_type);
1723 true
1724 }
1725 Err(e) => {
1726 log::error!(
1727 "[plugin:{}] Error refreshing pane type '{}': {e}",
1728 self.name,
1729 pane_type
1730 );
1731 ctx.notify("error", &format!("Plugin refresh error: {e}"));
1732 false
1733 }
1734 }
1735 }
1736
1737 fn execute_command(&mut self, command: &str, args: &str, ctx: &PluginContext) -> bool {
1738 let cmd = self
1740 .commands
1741 .iter()
1742 .find(|c| c.name == command || c.aliases.contains(&command.to_string()));
1743
1744 let Some(cmd) = cmd else {
1745 return false;
1746 };
1747
1748 let lua = self.lua.lock();
1749
1750 let result = lua.scope(|scope| {
1752 Self::setup_runtime_api(&lua, scope, ctx)?;
1754
1755 let callback: Function = lua.registry_value(&cmd.callback_key)?;
1757
1758 let success: bool = callback.call(args)?;
1760
1761 Ok(success)
1762 });
1763
1764 match result {
1765 Ok(success) => success,
1766 Err(e) => {
1767 log::error!(
1768 "[plugin:{}] Error executing command '{}': {e}",
1769 self.name,
1770 command
1771 );
1772 ctx.notify("error", &format!("Plugin error: {e}"));
1773 false
1774 }
1775 }
1776 }
1777
1778 fn as_any(&self) -> &dyn Any {
1779 self
1780 }
1781
1782 fn as_any_mut(&mut self) -> &mut dyn Any {
1783 self
1784 }
1785}
1786
1787pub const EXAMPLE_LUA_PLUGIN: &str = r#"-- Example Enya Lua Plugin
1789-- Place this file in ~/.enya/plugins/
1790
1791-- Plugin metadata (required)
1792plugin = {
1793 name = "example-lua",
1794 version = "0.1.0",
1795 description = "An example Lua plugin showing available features"
1796}
1797
1798-- Register a simple command
1799enya.register_command("lua-hello", {
1800 description = "Say hello from Lua",
1801 aliases = {"lhello"},
1802 accepts_args = true
1803}, function(args)
1804 if args == "" then
1805 enya.notify("info", "Hello from Lua!")
1806 else
1807 enya.notify("info", "Hello, " .. args .. "!")
1808 end
1809 return true
1810end)
1811
1812-- Register a command with conditional logic
1813enya.register_command("lua-check", {
1814 description = "Check something with conditional logic",
1815 accepts_args = true
1816}, function(args)
1817 local num = tonumber(args)
1818 if num == nil then
1819 enya.notify("error", "Please provide a number")
1820 return false
1821 end
1822
1823 if num > 100 then
1824 enya.notify("warn", "That's a large number: " .. tostring(num))
1825 elseif num < 0 then
1826 enya.notify("error", "Negative numbers not allowed")
1827 return false
1828 else
1829 enya.notify("info", "Number " .. tostring(num) .. " is valid")
1830 end
1831
1832 return true
1833end)
1834
1835-- Register keybindings
1836enya.keymap("Space+l+h", "lua-hello", "Lua hello")
1837enya.keymap("Space+l+c", "lua-check", "Lua check")
1838
1839-- Lifecycle hooks (optional)
1840function on_activate()
1841 enya.log("info", "Example Lua plugin activated!")
1842end
1843
1844function on_deactivate()
1845 enya.log("info", "Example Lua plugin deactivated!")
1846end
1847"#;
1848
1849#[cfg(test)]
1850mod tests {
1851 use super::*;
1852 use std::path::PathBuf;
1853
1854 #[test]
1855 fn test_load_simple_plugin() {
1856 let source = r#"
1857 plugin = {
1858 name = "test-plugin",
1859 version = "1.0.0",
1860 description = "A test plugin"
1861 }
1862
1863 enya.register_command("test-cmd", {
1864 description = "Test command",
1865 aliases = {"tc"},
1866 accepts_args = false
1867 }, function(args)
1868 return true
1869 end)
1870 "#;
1871
1872 let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1873
1874 assert_eq!(plugin.name(), "test-plugin");
1875 assert_eq!(plugin.version(), "1.0.0");
1876 assert_eq!(plugin.description(), "A test plugin");
1877 assert_eq!(plugin.commands().len(), 1);
1878 assert_eq!(plugin.commands()[0].name, "test-cmd");
1879 }
1880
1881 #[test]
1882 fn test_keybindings() {
1883 let source = r#"
1884 plugin = { name = "kb-test" }
1885
1886 enya.register_command("my-cmd", {}, function() return true end)
1887 enya.keymap("Space+t+t", "my-cmd", "Test binding", {"normal"})
1888 "#;
1889
1890 let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1891
1892 assert_eq!(plugin.keybindings().len(), 1);
1893 assert_eq!(plugin.keybindings()[0].keys, "Space+t+t");
1894 assert_eq!(plugin.keybindings()[0].command, "my-cmd");
1895 assert_eq!(plugin.keybindings()[0].modes, vec!["normal"]);
1896 }
1897
1898 #[test]
1899 fn test_missing_metadata() {
1900 let source = r#"
1901 -- No plugin table defined
1902 enya.register_command("test", {}, function() return true end)
1903 "#;
1904
1905 let result = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua"));
1906 assert!(result.is_err());
1907 }
1908
1909 #[test]
1910 fn test_syntax_error() {
1911 let source = r#"
1912 plugin = { name = "broken"
1913 -- Missing closing brace
1914 "#;
1915
1916 let result = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua"));
1917 assert!(result.is_err());
1918 }
1919
1920 #[test]
1921 fn test_theme_plugin() {
1922 let source = r##"
1923 plugin = {
1924 name = "tokyo-night-theme",
1925 version = "1.0.0",
1926 description = "Tokyo Night color theme"
1927 }
1928
1929 theme = {
1930 name = "tokyo-night",
1931 display_name = "Tokyo Night",
1932 base = "dark",
1933 colors = {
1934 bg_base = "#1a1b26",
1935 bg_surface = "#24283b",
1936 accent_primary = "#7aa2f7",
1937 accent_hover = "#89b4fa",
1938 success = "#9ece6a",
1939 error = "#f7768e",
1940 chart = {
1941 "#7aa2f7",
1942 "#9ece6a",
1943 "#e0af68",
1944 "#f7768e",
1945 }
1946 }
1947 }
1948 "##;
1949
1950 let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1951
1952 assert_eq!(plugin.name(), "tokyo-night-theme");
1953
1954 assert!(
1956 plugin
1957 .capabilities()
1958 .contains(PluginCapabilities::CUSTOM_THEMES)
1959 );
1960
1961 let themes = plugin.themes();
1963 assert_eq!(themes.len(), 1);
1964
1965 let theme = &themes[0];
1966 assert_eq!(theme.name, "tokyo-night");
1967 assert_eq!(theme.display_name, "Tokyo Night");
1968 assert_eq!(theme.base, ThemeBase::Dark);
1969
1970 assert_eq!(theme.colors.bg_base, Some(0x1a1b26));
1972 assert_eq!(theme.colors.bg_surface, Some(0x24283b));
1973 assert_eq!(theme.colors.accent_primary, Some(0x7aa2f7));
1974 assert_eq!(theme.colors.success, Some(0x9ece6a));
1975 assert_eq!(theme.colors.chart_palette.len(), 4);
1976 }
1977
1978 #[test]
1979 fn test_theme_with_light_base() {
1980 let source = r##"
1981 plugin = { name = "light-theme" }
1982
1983 theme = {
1984 name = "my-light",
1985 base = "light",
1986 colors = {
1987 bg_base = "#ffffff",
1988 text_primary = "#000000"
1989 }
1990 }
1991 "##;
1992
1993 let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1994
1995 let themes = plugin.themes();
1996 assert_eq!(themes.len(), 1);
1997 assert_eq!(themes[0].base, ThemeBase::Light);
1998 assert_eq!(themes[0].colors.bg_base, Some(0xffffff));
1999 assert_eq!(themes[0].colors.text_primary, Some(0x000000));
2000 }
2001
2002 #[test]
2003 fn test_plugin_without_theme() {
2004 let source = r#"
2005 plugin = { name = "no-theme" }
2006
2007 enya.register_command("test", {}, function() return true end)
2008 "#;
2009
2010 let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
2011
2012 assert!(
2014 !plugin
2015 .capabilities()
2016 .contains(PluginCapabilities::CUSTOM_THEMES)
2017 );
2018
2019 assert!(plugin.themes().is_empty());
2021 }
2022}