1use crate::input::commands::{get_all_commands, Command, CommandSource, Suggestion};
7use crate::input::fuzzy::fuzzy_match;
8use crate::input::keybindings::Action;
9use crate::input::keybindings::KeyContext;
10use std::sync::{Arc, RwLock};
11
12pub struct CommandRegistry {
17 builtin_commands: Vec<Command>,
19
20 plugin_commands: Arc<RwLock<Vec<Command>>>,
22
23 command_history: Vec<String>,
26}
27
28impl CommandRegistry {
29 const MAX_HISTORY_SIZE: usize = 50;
31
32 pub fn new() -> Self {
34 Self {
35 builtin_commands: get_all_commands(),
36 plugin_commands: Arc::new(RwLock::new(Vec::new())),
37 command_history: Vec::new(),
38 }
39 }
40
41 pub fn refresh_builtin_commands(&mut self) {
43 self.builtin_commands = get_all_commands();
44 }
45
46 pub fn record_usage(&mut self, command_name: &str) {
51 self.command_history.retain(|name| name != command_name);
53
54 self.command_history.insert(0, command_name.to_string());
56
57 if self.command_history.len() > Self::MAX_HISTORY_SIZE {
59 self.command_history.truncate(Self::MAX_HISTORY_SIZE);
60 }
61 }
62
63 fn history_position(&self, command_name: &str) -> Option<usize> {
66 self.command_history
67 .iter()
68 .position(|name| name == command_name)
69 }
70
71 pub fn register(&self, command: Command) {
79 tracing::debug!(
80 "CommandRegistry::register: name='{}', action={:?}",
81 command.name,
82 command.action
83 );
84 let mut commands = self.plugin_commands.write().unwrap();
85
86 commands.retain(|c| c.name != command.name);
88
89 commands.push(command);
91 tracing::debug!(
92 "CommandRegistry::register: plugin_commands now has {} items",
93 commands.len()
94 );
95 }
96
97 pub fn try_register(&self, command: Command) -> Result<(), (String, CommandSource)> {
103 let mut commands = self.plugin_commands.write().unwrap();
104
105 if let Some(existing) = commands.iter().find(|c| c.name == command.name) {
106 if existing.source == command.source {
108 commands.retain(|c| c.name != command.name);
109 commands.push(command);
110 return Ok(());
111 }
112 return Err((existing.name.clone(), existing.source.clone()));
113 }
114
115 commands.push(command);
116 Ok(())
117 }
118
119 pub fn unregister(&self, name: &str) {
121 let mut commands = self.plugin_commands.write().unwrap();
122 commands.retain(|c| c.name != name);
123 }
124
125 pub fn unregister_by_prefix(&self, prefix: &str) {
127 let mut commands = self.plugin_commands.write().unwrap();
128 commands.retain(|c| !c.name.starts_with(prefix));
129 }
130
131 pub fn unregister_by_plugin(&self, plugin_name: &str) {
133 let mut commands = self.plugin_commands.write().unwrap();
134 let before = commands.len();
135 commands.retain(|c| {
136 if let super::commands::CommandSource::Plugin(ref name) = c.source {
137 name != plugin_name
138 } else {
139 true
140 }
141 });
142 let removed = before - commands.len();
143 if removed > 0 {
144 tracing::debug!(
145 "Unregistered {} commands from plugin '{}'",
146 removed,
147 plugin_name
148 );
149 }
150 }
151
152 pub fn get_all(&self) -> Vec<Command> {
154 let mut all_commands = self.builtin_commands.clone();
155
156 let plugin_commands = self.plugin_commands.read().unwrap();
157 let plugin_count = plugin_commands.len();
158
159 let target_action =
161 crate::input::keybindings::Action::PluginAction("vi_mode_toggle".to_string());
162 let has_target = plugin_commands.iter().any(|c| c.action == target_action);
163 if has_target {
164 tracing::debug!("get_all: vi_mode_toggle found via comparison!");
165 } else if plugin_count > 0 {
166 tracing::debug!(
167 "get_all: {} plugin commands but vi_mode_toggle NOT found",
168 plugin_count
169 );
170 }
171
172 all_commands.extend(plugin_commands.iter().cloned());
173
174 tracing::trace!(
175 "CommandRegistry::get_all: {} builtin + {} plugin = {} total",
176 self.builtin_commands.len(),
177 plugin_count,
178 all_commands.len()
179 );
180 all_commands
181 }
182
183 #[allow(clippy::too_many_arguments)]
193 pub fn filter(
194 &self,
195 query: &str,
196 current_context: KeyContext,
197 keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
198 selection_active: bool,
199 active_custom_contexts: &std::collections::HashSet<String>,
200 active_buffer_mode: Option<&str>,
201 has_lsp_config: bool,
202 ) -> Vec<Suggestion> {
203 let commands = self.get_all();
204
205 let is_visible = |cmd: &Command| -> bool {
211 cmd.custom_contexts.is_empty()
212 || cmd.custom_contexts.iter().all(|ctx| {
213 active_custom_contexts.contains(ctx)
214 || active_buffer_mode.is_some_and(|mode| mode == ctx)
215 })
216 };
217
218 let is_available = |cmd: &Command| -> bool {
220 if cmd.contexts.contains(&KeyContext::Global) {
222 return true;
223 }
224
225 cmd.contexts.is_empty() || cmd.contexts.contains(¤t_context)
227 };
228
229 let current_context_ref = ¤t_context;
231 let make_suggestion =
232 |cmd: &Command, score: i32, localized_name: String, localized_desc: String| {
233 let mut available = is_available(cmd);
234 if cmd.action == Action::FindInSelection && !selection_active {
235 available = false;
236 }
237 if !has_lsp_config
239 && matches!(cmd.action, Action::LspRestart | Action::LspToggleForBuffer)
240 {
241 available = false;
242 }
243 let keybinding = keybinding_resolver
244 .get_keybinding_for_action(&cmd.action, current_context_ref.clone());
245 let history_pos = self.history_position(&cmd.name);
246
247 let suggestion = Suggestion::new(localized_name)
248 .with_description(localized_desc)
249 .set_disabled(!available)
250 .with_keybinding(keybinding)
251 .with_source(Some(cmd.source.clone()));
252 (suggestion, history_pos, score)
253 };
254
255 let mut suggestions: Vec<(Suggestion, Option<usize>, i32, u8)> = commands
259 .iter()
260 .filter(|cmd| is_visible(cmd))
261 .filter_map(|cmd| {
262 let localized_name = cmd.get_localized_name();
263 let name_result = fuzzy_match(query, &localized_name);
264 if name_result.matched {
265 let localized_desc = cmd.get_localized_description();
266 let (suggestion, hist, score) =
267 make_suggestion(cmd, name_result.score, localized_name, localized_desc);
268 Some((suggestion, hist, score, 0))
269 } else if !query.is_empty() {
270 let localized_desc = cmd.get_localized_description();
271 let desc_result = fuzzy_match(query, &localized_desc);
272 if desc_result.matched {
273 let (suggestion, hist, score) =
274 make_suggestion(cmd, desc_result.score, localized_name, localized_desc);
275 Some((suggestion, hist, score, 1))
276 } else {
277 None
278 }
279 } else {
280 None
281 }
282 })
283 .collect();
284
285 let has_query = !query.is_empty();
291 suggestions.sort_by(
292 |(a, a_hist, a_score, a_kind), (b, b_hist, b_score, b_kind)| {
293 match a.disabled.cmp(&b.disabled) {
295 std::cmp::Ordering::Equal => {}
296 other => return other,
297 }
298
299 if has_query {
300 match a_kind.cmp(b_kind) {
302 std::cmp::Ordering::Equal => {}
303 other => return other,
304 }
305
306 match b_score.cmp(a_score) {
308 std::cmp::Ordering::Equal => {}
309 other => return other,
310 }
311 }
312
313 match (a_hist, b_hist) {
315 (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
316 (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater,
318 (None, None) => a.text.cmp(&b.text), }
320 },
321 );
322
323 suggestions.into_iter().map(|(s, _, _, _)| s).collect()
325 }
326
327 pub fn plugin_command_count(&self) -> usize {
329 self.plugin_commands.read().unwrap().len()
330 }
331
332 pub fn total_command_count(&self) -> usize {
334 self.builtin_commands.len() + self.plugin_command_count()
335 }
336
337 pub fn find_by_name(&self, name: &str) -> Option<Command> {
339 {
341 let plugin_commands = self.plugin_commands.read().unwrap();
342 if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
343 return Some(cmd.clone());
344 }
345 }
346
347 self.builtin_commands
349 .iter()
350 .find(|c| c.name == name)
351 .cloned()
352 }
353}
354
355impl Default for CommandRegistry {
356 fn default() -> Self {
357 Self::new()
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::input::commands::CommandSource;
365 use crate::input::keybindings::Action;
366
367 #[test]
368 fn test_command_registry_creation() {
369 let registry = CommandRegistry::new();
370 assert!(registry.total_command_count() > 0); assert_eq!(registry.plugin_command_count(), 0); }
373
374 #[test]
375 fn test_register_command() {
376 let registry = CommandRegistry::new();
377
378 let custom_command = Command {
379 name: "Test Command".to_string(),
380 description: "A test command".to_string(),
381 action: Action::None,
382 contexts: vec![],
383 custom_contexts: vec![],
384 source: CommandSource::Builtin,
385 };
386
387 registry.register(custom_command.clone());
388 assert_eq!(registry.plugin_command_count(), 1);
389
390 let found = registry.find_by_name("Test Command");
391 assert!(found.is_some());
392 assert_eq!(found.unwrap().description, "A test command");
393 }
394
395 #[test]
396 fn test_unregister_command() {
397 let registry = CommandRegistry::new();
398
399 let custom_command = Command {
400 name: "Test Command".to_string(),
401 description: "A test command".to_string(),
402 action: Action::None,
403 contexts: vec![],
404 custom_contexts: vec![],
405 source: CommandSource::Builtin,
406 };
407
408 registry.register(custom_command);
409 assert_eq!(registry.plugin_command_count(), 1);
410
411 registry.unregister("Test Command");
412 assert_eq!(registry.plugin_command_count(), 0);
413 }
414
415 #[test]
416 fn test_register_replaces_existing() {
417 let registry = CommandRegistry::new();
418
419 let command1 = Command {
420 name: "Test Command".to_string(),
421 description: "First version".to_string(),
422 action: Action::None,
423 contexts: vec![],
424 custom_contexts: vec![],
425 source: CommandSource::Builtin,
426 };
427
428 let command2 = Command {
429 name: "Test Command".to_string(),
430 description: "Second version".to_string(),
431 action: Action::None,
432 contexts: vec![],
433 custom_contexts: vec![],
434 source: CommandSource::Builtin,
435 };
436
437 registry.register(command1);
438 assert_eq!(registry.plugin_command_count(), 1);
439
440 registry.register(command2);
441 assert_eq!(registry.plugin_command_count(), 1); let found = registry.find_by_name("Test Command").unwrap();
444 assert_eq!(found.description, "Second version");
445 }
446
447 #[test]
448 fn test_unregister_by_prefix() {
449 let registry = CommandRegistry::new();
450
451 registry.register(Command {
452 name: "Plugin A: Command 1".to_string(),
453 description: "".to_string(),
454 action: Action::None,
455 contexts: vec![],
456 custom_contexts: vec![],
457 source: CommandSource::Builtin,
458 });
459
460 registry.register(Command {
461 name: "Plugin A: Command 2".to_string(),
462 description: "".to_string(),
463 action: Action::None,
464 contexts: vec![],
465 custom_contexts: vec![],
466 source: CommandSource::Builtin,
467 });
468
469 registry.register(Command {
470 name: "Plugin B: Command".to_string(),
471 description: "".to_string(),
472 action: Action::None,
473 contexts: vec![],
474 custom_contexts: vec![],
475 source: CommandSource::Builtin,
476 });
477
478 assert_eq!(registry.plugin_command_count(), 3);
479
480 registry.unregister_by_prefix("Plugin A:");
481 assert_eq!(registry.plugin_command_count(), 1);
482
483 let remaining = registry.find_by_name("Plugin B: Command");
484 assert!(remaining.is_some());
485 }
486
487 #[test]
488 fn test_filter_commands() {
489 use crate::config::Config;
490 use crate::input::keybindings::KeybindingResolver;
491
492 let registry = CommandRegistry::new();
493 let config = Config::default();
494 let keybindings = KeybindingResolver::new(&config);
495
496 registry.register(Command {
497 name: "Test Save".to_string(),
498 description: "Test save command".to_string(),
499 action: Action::None,
500 contexts: vec![KeyContext::Normal],
501 custom_contexts: vec![],
502 source: CommandSource::Builtin,
503 });
504
505 let empty_contexts = std::collections::HashSet::new();
506 let results = registry.filter(
507 "save",
508 KeyContext::Normal,
509 &keybindings,
510 false,
511 &empty_contexts,
512 None,
513 true,
514 );
515 assert!(results.len() >= 2); let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
519 assert!(names.iter().any(|n| n.contains("Save")));
520 }
521
522 #[test]
523 fn test_context_filtering() {
524 use crate::config::Config;
525 use crate::input::keybindings::KeybindingResolver;
526
527 let registry = CommandRegistry::new();
528 let config = Config::default();
529 let keybindings = KeybindingResolver::new(&config);
530
531 registry.register(Command {
532 name: "Normal Only".to_string(),
533 description: "Available only in normal context".to_string(),
534 action: Action::None,
535 contexts: vec![KeyContext::Normal],
536 custom_contexts: vec![],
537 source: CommandSource::Builtin,
538 });
539
540 registry.register(Command {
541 name: "Popup Only".to_string(),
542 description: "Available only in popup context".to_string(),
543 action: Action::None,
544 contexts: vec![KeyContext::Popup],
545 custom_contexts: vec![],
546 source: CommandSource::Builtin,
547 });
548
549 let empty_contexts = std::collections::HashSet::new();
551 let results = registry.filter(
552 "",
553 KeyContext::Normal,
554 &keybindings,
555 false,
556 &empty_contexts,
557 None,
558 true,
559 );
560 let popup_only = results.iter().find(|s| s.text == "Popup Only");
561 assert!(popup_only.is_some());
562 assert!(popup_only.unwrap().disabled);
563
564 let results = registry.filter(
566 "",
567 KeyContext::Popup,
568 &keybindings,
569 false,
570 &empty_contexts,
571 None,
572 true,
573 );
574 let normal_only = results.iter().find(|s| s.text == "Normal Only");
575 assert!(normal_only.is_some());
576 assert!(normal_only.unwrap().disabled);
577 }
578
579 #[test]
580 fn test_get_all_merges_commands() {
581 let registry = CommandRegistry::new();
582 let initial_count = registry.total_command_count();
583
584 registry.register(Command {
585 name: "Custom 1".to_string(),
586 description: "".to_string(),
587 action: Action::None,
588 contexts: vec![],
589 custom_contexts: vec![],
590 source: CommandSource::Builtin,
591 });
592
593 registry.register(Command {
594 name: "Custom 2".to_string(),
595 description: "".to_string(),
596 action: Action::None,
597 contexts: vec![],
598 custom_contexts: vec![],
599 source: CommandSource::Builtin,
600 });
601
602 let all = registry.get_all();
603 assert_eq!(all.len(), initial_count + 2);
604 }
605
606 #[test]
607 fn test_plugin_command_overrides_builtin() {
608 let registry = CommandRegistry::new();
609
610 let builtin = registry.find_by_name("Save File");
612 assert!(builtin.is_some());
613 let original_desc = builtin.unwrap().description;
614
615 registry.register(Command {
617 name: "Save File".to_string(),
618 description: "Custom save implementation".to_string(),
619 action: Action::None,
620 contexts: vec![],
621 custom_contexts: vec![],
622 source: CommandSource::Builtin,
623 });
624
625 let custom = registry.find_by_name("Save File").unwrap();
627 assert_eq!(custom.description, "Custom save implementation");
628 assert_ne!(custom.description, original_desc);
629 }
630
631 #[test]
632 fn test_record_usage() {
633 let mut registry = CommandRegistry::new();
634
635 registry.record_usage("Save File");
636 assert_eq!(registry.history_position("Save File"), Some(0));
637
638 registry.record_usage("Open File");
639 assert_eq!(registry.history_position("Open File"), Some(0));
640 assert_eq!(registry.history_position("Save File"), Some(1));
641
642 registry.record_usage("Save File");
644 assert_eq!(registry.history_position("Save File"), Some(0));
645 assert_eq!(registry.history_position("Open File"), Some(1));
646 }
647
648 #[test]
649 fn test_history_sorting() {
650 use crate::config::Config;
651 use crate::input::keybindings::KeybindingResolver;
652
653 let mut registry = CommandRegistry::new();
654 let config = Config::default();
655 let keybindings = KeybindingResolver::new(&config);
656
657 registry.record_usage("Quit");
659 registry.record_usage("Save File");
660 registry.record_usage("Open File");
661
662 let empty_contexts = std::collections::HashSet::new();
664 let results = registry.filter(
665 "",
666 KeyContext::Normal,
667 &keybindings,
668 false,
669 &empty_contexts,
670 None,
671 true,
672 );
673
674 let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
676 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
677 let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
678
679 assert!(
681 open_pos < save_pos,
682 "Open File should come before Save File"
683 );
684 assert!(save_pos < quit_pos, "Save File should come before Quit");
685 }
686
687 #[test]
688 fn test_history_max_size() {
689 let mut registry = CommandRegistry::new();
690
691 for i in 0..60 {
693 registry.record_usage(&format!("Command {}", i));
694 }
695
696 assert_eq!(
698 registry.command_history.len(),
699 CommandRegistry::MAX_HISTORY_SIZE
700 );
701
702 assert_eq!(registry.history_position("Command 59"), Some(0));
704
705 assert_eq!(registry.history_position("Command 0"), None);
707 }
708
709 #[test]
710 fn test_unused_commands_alphabetical() {
711 use crate::config::Config;
712 use crate::input::keybindings::KeybindingResolver;
713
714 let mut registry = CommandRegistry::new();
715 let config = Config::default();
716 let keybindings = KeybindingResolver::new(&config);
717
718 registry.register(Command {
720 name: "Zebra Command".to_string(),
721 description: "".to_string(),
722 action: Action::None,
723 contexts: vec![],
724 custom_contexts: vec![],
725 source: CommandSource::Builtin,
726 });
727
728 registry.register(Command {
729 name: "Alpha Command".to_string(),
730 description: "".to_string(),
731 action: Action::None,
732 contexts: vec![],
733 custom_contexts: vec![],
734 source: CommandSource::Builtin,
735 });
736
737 registry.record_usage("Save File");
739
740 let empty_contexts = std::collections::HashSet::new();
741 let results = registry.filter(
742 "",
743 KeyContext::Normal,
744 &keybindings,
745 false,
746 &empty_contexts,
747 None,
748 true,
749 );
750
751 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
752 let alpha_pos = results
753 .iter()
754 .position(|s| s.text == "Alpha Command")
755 .unwrap();
756 let zebra_pos = results
757 .iter()
758 .position(|s| s.text == "Zebra Command")
759 .unwrap();
760
761 assert!(
763 save_pos < alpha_pos,
764 "Save File should come before Alpha Command"
765 );
766 assert!(
768 alpha_pos < zebra_pos,
769 "Alpha Command should come before Zebra Command"
770 );
771 }
772
773 #[test]
774 fn test_required_commands_exist() {
775 crate::i18n::set_locale("en");
778 let registry = CommandRegistry::new();
779
780 let required_commands = [
781 ("Show Completions", Action::LspCompletion),
783 ("Go to Definition", Action::LspGotoDefinition),
784 ("Show Hover Info", Action::LspHover),
785 ("Find References", Action::LspReferences),
786 ("Show Manual", Action::ShowHelp),
788 ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
789 ("Scroll Up", Action::ScrollUp),
791 ("Scroll Down", Action::ScrollDown),
792 ("Scroll Tabs Left", Action::ScrollTabsLeft),
793 ("Scroll Tabs Right", Action::ScrollTabsRight),
794 ("Smart Home", Action::SmartHome),
796 ("Delete Word Backward", Action::DeleteWordBackward),
798 ("Delete Word Forward", Action::DeleteWordForward),
799 ("Delete to End of Line", Action::DeleteToLineEnd),
800 ];
801
802 for (name, expected_action) in required_commands {
803 let cmd = registry.find_by_name(name);
804 assert!(
805 cmd.is_some(),
806 "Command '{}' should exist in command palette",
807 name
808 );
809 assert_eq!(
810 cmd.unwrap().action,
811 expected_action,
812 "Command '{}' should have action {:?}",
813 name,
814 expected_action
815 );
816 }
817 }
818
819 #[test]
820 fn test_try_register_first_writer_wins() {
821 let registry = CommandRegistry::new();
822
823 let cmd_a = Command {
824 name: "My Command".to_string(),
825 description: "From plugin A".to_string(),
826 action: Action::None,
827 contexts: vec![],
828 custom_contexts: vec![],
829 source: CommandSource::Plugin("plugin-a".to_string()),
830 };
831
832 let cmd_b = Command {
833 name: "My Command".to_string(),
834 description: "From plugin B".to_string(),
835 action: Action::None,
836 contexts: vec![],
837 custom_contexts: vec![],
838 source: CommandSource::Plugin("plugin-b".to_string()),
839 };
840
841 assert!(registry.try_register(cmd_a).is_ok());
843 assert_eq!(registry.plugin_command_count(), 1);
844
845 let result = registry.try_register(cmd_b);
847 assert!(result.is_err());
848 assert_eq!(registry.plugin_command_count(), 1);
849
850 let found = registry.find_by_name("My Command").unwrap();
852 assert_eq!(found.description, "From plugin A");
853 }
854
855 #[test]
856 fn test_try_register_same_plugin_allowed() {
857 let registry = CommandRegistry::new();
858
859 let cmd1 = Command {
860 name: "My Command".to_string(),
861 description: "Version 1".to_string(),
862 action: Action::None,
863 contexts: vec![],
864 custom_contexts: vec![],
865 source: CommandSource::Plugin("plugin-a".to_string()),
866 };
867
868 let cmd2 = Command {
869 name: "My Command".to_string(),
870 description: "Version 2".to_string(),
871 action: Action::None,
872 contexts: vec![],
873 custom_contexts: vec![],
874 source: CommandSource::Plugin("plugin-a".to_string()),
875 };
876
877 assert!(registry.try_register(cmd1).is_ok());
878 assert!(registry.try_register(cmd2).is_ok());
880 assert_eq!(registry.plugin_command_count(), 1);
881
882 let found = registry.find_by_name("My Command").unwrap();
883 assert_eq!(found.description, "Version 2");
884 }
885
886 #[test]
887 fn test_try_register_after_unregister() {
888 let registry = CommandRegistry::new();
889
890 let cmd_a = Command {
891 name: "My Command".to_string(),
892 description: "From plugin A".to_string(),
893 action: Action::None,
894 contexts: vec![],
895 custom_contexts: vec![],
896 source: CommandSource::Plugin("plugin-a".to_string()),
897 };
898
899 let cmd_b = Command {
900 name: "My Command".to_string(),
901 description: "From plugin B".to_string(),
902 action: Action::None,
903 contexts: vec![],
904 custom_contexts: vec![],
905 source: CommandSource::Plugin("plugin-b".to_string()),
906 };
907
908 assert!(registry.try_register(cmd_a).is_ok());
910
911 registry.unregister("My Command");
913 assert_eq!(registry.plugin_command_count(), 0);
914
915 assert!(registry.try_register(cmd_b).is_ok());
917 let found = registry.find_by_name("My Command").unwrap();
918 assert_eq!(found.description, "From plugin B");
919 }
920}