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::with_source(
248 localized_name,
249 Some(localized_desc),
250 !available,
251 keybinding,
252 Some(cmd.source.clone()),
253 );
254 (suggestion, history_pos, score)
255 };
256
257 let mut suggestions: Vec<(Suggestion, Option<usize>, i32, u8)> = commands
261 .iter()
262 .filter(|cmd| is_visible(cmd))
263 .filter_map(|cmd| {
264 let localized_name = cmd.get_localized_name();
265 let name_result = fuzzy_match(query, &localized_name);
266 if name_result.matched {
267 let localized_desc = cmd.get_localized_description();
268 let (suggestion, hist, score) =
269 make_suggestion(cmd, name_result.score, localized_name, localized_desc);
270 Some((suggestion, hist, score, 0))
271 } else if !query.is_empty() {
272 let localized_desc = cmd.get_localized_description();
273 let desc_result = fuzzy_match(query, &localized_desc);
274 if desc_result.matched {
275 let (suggestion, hist, score) =
276 make_suggestion(cmd, desc_result.score, localized_name, localized_desc);
277 Some((suggestion, hist, score, 1))
278 } else {
279 None
280 }
281 } else {
282 None
283 }
284 })
285 .collect();
286
287 let has_query = !query.is_empty();
293 suggestions.sort_by(
294 |(a, a_hist, a_score, a_kind), (b, b_hist, b_score, b_kind)| {
295 match a.disabled.cmp(&b.disabled) {
297 std::cmp::Ordering::Equal => {}
298 other => return other,
299 }
300
301 if has_query {
302 match a_kind.cmp(b_kind) {
304 std::cmp::Ordering::Equal => {}
305 other => return other,
306 }
307
308 match b_score.cmp(a_score) {
310 std::cmp::Ordering::Equal => {}
311 other => return other,
312 }
313 }
314
315 match (a_hist, b_hist) {
317 (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
318 (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater,
320 (None, None) => a.text.cmp(&b.text), }
322 },
323 );
324
325 suggestions.into_iter().map(|(s, _, _, _)| s).collect()
327 }
328
329 pub fn plugin_command_count(&self) -> usize {
331 self.plugin_commands.read().unwrap().len()
332 }
333
334 pub fn total_command_count(&self) -> usize {
336 self.builtin_commands.len() + self.plugin_command_count()
337 }
338
339 pub fn find_by_name(&self, name: &str) -> Option<Command> {
341 {
343 let plugin_commands = self.plugin_commands.read().unwrap();
344 if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
345 return Some(cmd.clone());
346 }
347 }
348
349 self.builtin_commands
351 .iter()
352 .find(|c| c.name == name)
353 .cloned()
354 }
355}
356
357impl Default for CommandRegistry {
358 fn default() -> Self {
359 Self::new()
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::input::commands::CommandSource;
367 use crate::input::keybindings::Action;
368
369 #[test]
370 fn test_command_registry_creation() {
371 let registry = CommandRegistry::new();
372 assert!(registry.total_command_count() > 0); assert_eq!(registry.plugin_command_count(), 0); }
375
376 #[test]
377 fn test_register_command() {
378 let registry = CommandRegistry::new();
379
380 let custom_command = Command {
381 name: "Test Command".to_string(),
382 description: "A test command".to_string(),
383 action: Action::None,
384 contexts: vec![],
385 custom_contexts: vec![],
386 source: CommandSource::Builtin,
387 };
388
389 registry.register(custom_command.clone());
390 assert_eq!(registry.plugin_command_count(), 1);
391
392 let found = registry.find_by_name("Test Command");
393 assert!(found.is_some());
394 assert_eq!(found.unwrap().description, "A test command");
395 }
396
397 #[test]
398 fn test_unregister_command() {
399 let registry = CommandRegistry::new();
400
401 let custom_command = Command {
402 name: "Test Command".to_string(),
403 description: "A test command".to_string(),
404 action: Action::None,
405 contexts: vec![],
406 custom_contexts: vec![],
407 source: CommandSource::Builtin,
408 };
409
410 registry.register(custom_command);
411 assert_eq!(registry.plugin_command_count(), 1);
412
413 registry.unregister("Test Command");
414 assert_eq!(registry.plugin_command_count(), 0);
415 }
416
417 #[test]
418 fn test_register_replaces_existing() {
419 let registry = CommandRegistry::new();
420
421 let command1 = Command {
422 name: "Test Command".to_string(),
423 description: "First version".to_string(),
424 action: Action::None,
425 contexts: vec![],
426 custom_contexts: vec![],
427 source: CommandSource::Builtin,
428 };
429
430 let command2 = Command {
431 name: "Test Command".to_string(),
432 description: "Second version".to_string(),
433 action: Action::None,
434 contexts: vec![],
435 custom_contexts: vec![],
436 source: CommandSource::Builtin,
437 };
438
439 registry.register(command1);
440 assert_eq!(registry.plugin_command_count(), 1);
441
442 registry.register(command2);
443 assert_eq!(registry.plugin_command_count(), 1); let found = registry.find_by_name("Test Command").unwrap();
446 assert_eq!(found.description, "Second version");
447 }
448
449 #[test]
450 fn test_unregister_by_prefix() {
451 let registry = CommandRegistry::new();
452
453 registry.register(Command {
454 name: "Plugin A: Command 1".to_string(),
455 description: "".to_string(),
456 action: Action::None,
457 contexts: vec![],
458 custom_contexts: vec![],
459 source: CommandSource::Builtin,
460 });
461
462 registry.register(Command {
463 name: "Plugin A: Command 2".to_string(),
464 description: "".to_string(),
465 action: Action::None,
466 contexts: vec![],
467 custom_contexts: vec![],
468 source: CommandSource::Builtin,
469 });
470
471 registry.register(Command {
472 name: "Plugin B: Command".to_string(),
473 description: "".to_string(),
474 action: Action::None,
475 contexts: vec![],
476 custom_contexts: vec![],
477 source: CommandSource::Builtin,
478 });
479
480 assert_eq!(registry.plugin_command_count(), 3);
481
482 registry.unregister_by_prefix("Plugin A:");
483 assert_eq!(registry.plugin_command_count(), 1);
484
485 let remaining = registry.find_by_name("Plugin B: Command");
486 assert!(remaining.is_some());
487 }
488
489 #[test]
490 fn test_filter_commands() {
491 use crate::config::Config;
492 use crate::input::keybindings::KeybindingResolver;
493
494 let registry = CommandRegistry::new();
495 let config = Config::default();
496 let keybindings = KeybindingResolver::new(&config);
497
498 registry.register(Command {
499 name: "Test Save".to_string(),
500 description: "Test save command".to_string(),
501 action: Action::None,
502 contexts: vec![KeyContext::Normal],
503 custom_contexts: vec![],
504 source: CommandSource::Builtin,
505 });
506
507 let empty_contexts = std::collections::HashSet::new();
508 let results = registry.filter(
509 "save",
510 KeyContext::Normal,
511 &keybindings,
512 false,
513 &empty_contexts,
514 None,
515 true,
516 );
517 assert!(results.len() >= 2); let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
521 assert!(names.iter().any(|n| n.contains("Save")));
522 }
523
524 #[test]
525 fn test_context_filtering() {
526 use crate::config::Config;
527 use crate::input::keybindings::KeybindingResolver;
528
529 let registry = CommandRegistry::new();
530 let config = Config::default();
531 let keybindings = KeybindingResolver::new(&config);
532
533 registry.register(Command {
534 name: "Normal Only".to_string(),
535 description: "Available only in normal context".to_string(),
536 action: Action::None,
537 contexts: vec![KeyContext::Normal],
538 custom_contexts: vec![],
539 source: CommandSource::Builtin,
540 });
541
542 registry.register(Command {
543 name: "Popup Only".to_string(),
544 description: "Available only in popup context".to_string(),
545 action: Action::None,
546 contexts: vec![KeyContext::Popup],
547 custom_contexts: vec![],
548 source: CommandSource::Builtin,
549 });
550
551 let empty_contexts = std::collections::HashSet::new();
553 let results = registry.filter(
554 "",
555 KeyContext::Normal,
556 &keybindings,
557 false,
558 &empty_contexts,
559 None,
560 true,
561 );
562 let popup_only = results.iter().find(|s| s.text == "Popup Only");
563 assert!(popup_only.is_some());
564 assert!(popup_only.unwrap().disabled);
565
566 let results = registry.filter(
568 "",
569 KeyContext::Popup,
570 &keybindings,
571 false,
572 &empty_contexts,
573 None,
574 true,
575 );
576 let normal_only = results.iter().find(|s| s.text == "Normal Only");
577 assert!(normal_only.is_some());
578 assert!(normal_only.unwrap().disabled);
579 }
580
581 #[test]
582 fn test_get_all_merges_commands() {
583 let registry = CommandRegistry::new();
584 let initial_count = registry.total_command_count();
585
586 registry.register(Command {
587 name: "Custom 1".to_string(),
588 description: "".to_string(),
589 action: Action::None,
590 contexts: vec![],
591 custom_contexts: vec![],
592 source: CommandSource::Builtin,
593 });
594
595 registry.register(Command {
596 name: "Custom 2".to_string(),
597 description: "".to_string(),
598 action: Action::None,
599 contexts: vec![],
600 custom_contexts: vec![],
601 source: CommandSource::Builtin,
602 });
603
604 let all = registry.get_all();
605 assert_eq!(all.len(), initial_count + 2);
606 }
607
608 #[test]
609 fn test_plugin_command_overrides_builtin() {
610 let registry = CommandRegistry::new();
611
612 let builtin = registry.find_by_name("Save File");
614 assert!(builtin.is_some());
615 let original_desc = builtin.unwrap().description;
616
617 registry.register(Command {
619 name: "Save File".to_string(),
620 description: "Custom save implementation".to_string(),
621 action: Action::None,
622 contexts: vec![],
623 custom_contexts: vec![],
624 source: CommandSource::Builtin,
625 });
626
627 let custom = registry.find_by_name("Save File").unwrap();
629 assert_eq!(custom.description, "Custom save implementation");
630 assert_ne!(custom.description, original_desc);
631 }
632
633 #[test]
634 fn test_record_usage() {
635 let mut registry = CommandRegistry::new();
636
637 registry.record_usage("Save File");
638 assert_eq!(registry.history_position("Save File"), Some(0));
639
640 registry.record_usage("Open File");
641 assert_eq!(registry.history_position("Open File"), Some(0));
642 assert_eq!(registry.history_position("Save File"), Some(1));
643
644 registry.record_usage("Save File");
646 assert_eq!(registry.history_position("Save File"), Some(0));
647 assert_eq!(registry.history_position("Open File"), Some(1));
648 }
649
650 #[test]
651 fn test_history_sorting() {
652 use crate::config::Config;
653 use crate::input::keybindings::KeybindingResolver;
654
655 let mut registry = CommandRegistry::new();
656 let config = Config::default();
657 let keybindings = KeybindingResolver::new(&config);
658
659 registry.record_usage("Quit");
661 registry.record_usage("Save File");
662 registry.record_usage("Open File");
663
664 let empty_contexts = std::collections::HashSet::new();
666 let results = registry.filter(
667 "",
668 KeyContext::Normal,
669 &keybindings,
670 false,
671 &empty_contexts,
672 None,
673 true,
674 );
675
676 let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
678 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
679 let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
680
681 assert!(
683 open_pos < save_pos,
684 "Open File should come before Save File"
685 );
686 assert!(save_pos < quit_pos, "Save File should come before Quit");
687 }
688
689 #[test]
690 fn test_history_max_size() {
691 let mut registry = CommandRegistry::new();
692
693 for i in 0..60 {
695 registry.record_usage(&format!("Command {}", i));
696 }
697
698 assert_eq!(
700 registry.command_history.len(),
701 CommandRegistry::MAX_HISTORY_SIZE
702 );
703
704 assert_eq!(registry.history_position("Command 59"), Some(0));
706
707 assert_eq!(registry.history_position("Command 0"), None);
709 }
710
711 #[test]
712 fn test_unused_commands_alphabetical() {
713 use crate::config::Config;
714 use crate::input::keybindings::KeybindingResolver;
715
716 let mut registry = CommandRegistry::new();
717 let config = Config::default();
718 let keybindings = KeybindingResolver::new(&config);
719
720 registry.register(Command {
722 name: "Zebra Command".to_string(),
723 description: "".to_string(),
724 action: Action::None,
725 contexts: vec![],
726 custom_contexts: vec![],
727 source: CommandSource::Builtin,
728 });
729
730 registry.register(Command {
731 name: "Alpha Command".to_string(),
732 description: "".to_string(),
733 action: Action::None,
734 contexts: vec![],
735 custom_contexts: vec![],
736 source: CommandSource::Builtin,
737 });
738
739 registry.record_usage("Save File");
741
742 let empty_contexts = std::collections::HashSet::new();
743 let results = registry.filter(
744 "",
745 KeyContext::Normal,
746 &keybindings,
747 false,
748 &empty_contexts,
749 None,
750 true,
751 );
752
753 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
754 let alpha_pos = results
755 .iter()
756 .position(|s| s.text == "Alpha Command")
757 .unwrap();
758 let zebra_pos = results
759 .iter()
760 .position(|s| s.text == "Zebra Command")
761 .unwrap();
762
763 assert!(
765 save_pos < alpha_pos,
766 "Save File should come before Alpha Command"
767 );
768 assert!(
770 alpha_pos < zebra_pos,
771 "Alpha Command should come before Zebra Command"
772 );
773 }
774
775 #[test]
776 fn test_required_commands_exist() {
777 crate::i18n::set_locale("en");
780 let registry = CommandRegistry::new();
781
782 let required_commands = [
783 ("Show Completions", Action::LspCompletion),
785 ("Go to Definition", Action::LspGotoDefinition),
786 ("Show Hover Info", Action::LspHover),
787 ("Find References", Action::LspReferences),
788 ("Show Manual", Action::ShowHelp),
790 ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
791 ("Scroll Up", Action::ScrollUp),
793 ("Scroll Down", Action::ScrollDown),
794 ("Scroll Tabs Left", Action::ScrollTabsLeft),
795 ("Scroll Tabs Right", Action::ScrollTabsRight),
796 ("Smart Home", Action::SmartHome),
798 ("Delete Word Backward", Action::DeleteWordBackward),
800 ("Delete Word Forward", Action::DeleteWordForward),
801 ("Delete to End of Line", Action::DeleteToLineEnd),
802 ];
803
804 for (name, expected_action) in required_commands {
805 let cmd = registry.find_by_name(name);
806 assert!(
807 cmd.is_some(),
808 "Command '{}' should exist in command palette",
809 name
810 );
811 assert_eq!(
812 cmd.unwrap().action,
813 expected_action,
814 "Command '{}' should have action {:?}",
815 name,
816 expected_action
817 );
818 }
819 }
820
821 #[test]
822 fn test_try_register_first_writer_wins() {
823 let registry = CommandRegistry::new();
824
825 let cmd_a = Command {
826 name: "My Command".to_string(),
827 description: "From plugin A".to_string(),
828 action: Action::None,
829 contexts: vec![],
830 custom_contexts: vec![],
831 source: CommandSource::Plugin("plugin-a".to_string()),
832 };
833
834 let cmd_b = Command {
835 name: "My Command".to_string(),
836 description: "From plugin B".to_string(),
837 action: Action::None,
838 contexts: vec![],
839 custom_contexts: vec![],
840 source: CommandSource::Plugin("plugin-b".to_string()),
841 };
842
843 assert!(registry.try_register(cmd_a).is_ok());
845 assert_eq!(registry.plugin_command_count(), 1);
846
847 let result = registry.try_register(cmd_b);
849 assert!(result.is_err());
850 assert_eq!(registry.plugin_command_count(), 1);
851
852 let found = registry.find_by_name("My Command").unwrap();
854 assert_eq!(found.description, "From plugin A");
855 }
856
857 #[test]
858 fn test_try_register_same_plugin_allowed() {
859 let registry = CommandRegistry::new();
860
861 let cmd1 = Command {
862 name: "My Command".to_string(),
863 description: "Version 1".to_string(),
864 action: Action::None,
865 contexts: vec![],
866 custom_contexts: vec![],
867 source: CommandSource::Plugin("plugin-a".to_string()),
868 };
869
870 let cmd2 = Command {
871 name: "My Command".to_string(),
872 description: "Version 2".to_string(),
873 action: Action::None,
874 contexts: vec![],
875 custom_contexts: vec![],
876 source: CommandSource::Plugin("plugin-a".to_string()),
877 };
878
879 assert!(registry.try_register(cmd1).is_ok());
880 assert!(registry.try_register(cmd2).is_ok());
882 assert_eq!(registry.plugin_command_count(), 1);
883
884 let found = registry.find_by_name("My Command").unwrap();
885 assert_eq!(found.description, "Version 2");
886 }
887
888 #[test]
889 fn test_try_register_after_unregister() {
890 let registry = CommandRegistry::new();
891
892 let cmd_a = Command {
893 name: "My Command".to_string(),
894 description: "From plugin A".to_string(),
895 action: Action::None,
896 contexts: vec![],
897 custom_contexts: vec![],
898 source: CommandSource::Plugin("plugin-a".to_string()),
899 };
900
901 let cmd_b = Command {
902 name: "My Command".to_string(),
903 description: "From plugin B".to_string(),
904 action: Action::None,
905 contexts: vec![],
906 custom_contexts: vec![],
907 source: CommandSource::Plugin("plugin-b".to_string()),
908 };
909
910 assert!(registry.try_register(cmd_a).is_ok());
912
913 registry.unregister("My Command");
915 assert_eq!(registry.plugin_command_count(), 0);
916
917 assert!(registry.try_register(cmd_b).is_ok());
919 let found = registry.find_by_name("My Command").unwrap();
920 assert_eq!(found.description, "From plugin B");
921 }
922}