1use crate::input::commands::{get_all_commands, Command, 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) {
76 tracing::debug!(
77 "CommandRegistry::register: name='{}', action={:?}",
78 command.name,
79 command.action
80 );
81 let mut commands = self.plugin_commands.write().unwrap();
82
83 commands.retain(|c| c.name != command.name);
85
86 commands.push(command);
88 tracing::debug!(
89 "CommandRegistry::register: plugin_commands now has {} items",
90 commands.len()
91 );
92 }
93
94 pub fn unregister(&self, name: &str) {
96 let mut commands = self.plugin_commands.write().unwrap();
97 commands.retain(|c| c.name != name);
98 }
99
100 pub fn unregister_by_prefix(&self, prefix: &str) {
102 let mut commands = self.plugin_commands.write().unwrap();
103 commands.retain(|c| !c.name.starts_with(prefix));
104 }
105
106 pub fn unregister_by_plugin(&self, plugin_name: &str) {
108 let mut commands = self.plugin_commands.write().unwrap();
109 let before = commands.len();
110 commands.retain(|c| {
111 if let super::commands::CommandSource::Plugin(ref name) = c.source {
112 name != plugin_name
113 } else {
114 true
115 }
116 });
117 let removed = before - commands.len();
118 if removed > 0 {
119 tracing::debug!(
120 "Unregistered {} commands from plugin '{}'",
121 removed,
122 plugin_name
123 );
124 }
125 }
126
127 pub fn get_all(&self) -> Vec<Command> {
129 let mut all_commands = self.builtin_commands.clone();
130
131 let plugin_commands = self.plugin_commands.read().unwrap();
132 let plugin_count = plugin_commands.len();
133
134 let target_action =
136 crate::input::keybindings::Action::PluginAction("vi_mode_toggle".to_string());
137 let has_target = plugin_commands.iter().any(|c| c.action == target_action);
138 if has_target {
139 tracing::debug!("get_all: vi_mode_toggle found via comparison!");
140 } else if plugin_count > 0 {
141 tracing::debug!(
142 "get_all: {} plugin commands but vi_mode_toggle NOT found",
143 plugin_count
144 );
145 }
146
147 all_commands.extend(plugin_commands.iter().cloned());
148
149 tracing::trace!(
150 "CommandRegistry::get_all: {} builtin + {} plugin = {} total",
151 self.builtin_commands.len(),
152 plugin_count,
153 all_commands.len()
154 );
155 all_commands
156 }
157
158 pub fn filter(
168 &self,
169 query: &str,
170 current_context: KeyContext,
171 keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
172 selection_active: bool,
173 active_custom_contexts: &std::collections::HashSet<String>,
174 active_buffer_mode: Option<&str>,
175 has_lsp_config: bool,
176 ) -> Vec<Suggestion> {
177 let commands = self.get_all();
178
179 let is_visible = |cmd: &Command| -> bool {
185 cmd.custom_contexts.is_empty()
186 || cmd.custom_contexts.iter().all(|ctx| {
187 active_custom_contexts.contains(ctx)
188 || active_buffer_mode.is_some_and(|mode| mode == ctx)
189 })
190 };
191
192 let is_available = |cmd: &Command| -> bool {
194 if cmd.contexts.contains(&KeyContext::Global) {
196 return true;
197 }
198
199 cmd.contexts.is_empty() || cmd.contexts.contains(¤t_context)
201 };
202
203 let make_suggestion =
205 |cmd: &Command, score: i32, localized_name: String, localized_desc: String| {
206 let mut available = is_available(cmd);
207 if cmd.action == Action::FindInSelection && !selection_active {
208 available = false;
209 }
210 if !has_lsp_config
212 && matches!(cmd.action, Action::LspRestart | Action::LspToggleForBuffer)
213 {
214 available = false;
215 }
216 let keybinding =
217 keybinding_resolver.get_keybinding_for_action(&cmd.action, current_context);
218 let history_pos = self.history_position(&cmd.name);
219
220 let suggestion = Suggestion::with_source(
221 localized_name,
222 Some(localized_desc),
223 !available,
224 keybinding,
225 Some(cmd.source.clone()),
226 );
227 (suggestion, history_pos, score)
228 };
229
230 let mut suggestions: Vec<(Suggestion, Option<usize>, i32)> = commands
233 .iter()
234 .filter(|cmd| is_visible(cmd))
235 .filter_map(|cmd| {
236 let localized_name = cmd.get_localized_name();
237 let name_result = fuzzy_match(query, &localized_name);
238 if name_result.matched {
239 let localized_desc = cmd.get_localized_description();
240 Some(make_suggestion(
241 cmd,
242 name_result.score,
243 localized_name,
244 localized_desc,
245 ))
246 } else {
247 None
248 }
249 })
250 .collect();
251
252 if suggestions.is_empty() && !query.is_empty() {
254 suggestions = commands
255 .iter()
256 .filter(|cmd| is_visible(cmd))
257 .filter_map(|cmd| {
258 let localized_desc = cmd.get_localized_description();
259 let desc_result = fuzzy_match(query, &localized_desc);
260 if desc_result.matched {
261 let localized_name = cmd.get_localized_name();
262 Some(make_suggestion(
264 cmd,
265 desc_result.score.saturating_sub(50),
266 localized_name,
267 localized_desc,
268 ))
269 } else {
270 None
271 }
272 })
273 .collect();
274 }
275
276 let has_query = !query.is_empty();
281 suggestions.sort_by(|(a, a_hist, a_score), (b, b_hist, b_score)| {
282 match a.disabled.cmp(&b.disabled) {
284 std::cmp::Ordering::Equal => {}
285 other => return other,
286 }
287
288 if has_query {
290 match b_score.cmp(a_score) {
291 std::cmp::Ordering::Equal => {}
292 other => return other,
293 }
294 }
295
296 match (a_hist, b_hist) {
298 (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
299 (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater,
301 (None, None) => a.text.cmp(&b.text), }
303 });
304
305 suggestions.into_iter().map(|(s, _, _)| s).collect()
307 }
308
309 pub fn plugin_command_count(&self) -> usize {
311 self.plugin_commands.read().unwrap().len()
312 }
313
314 pub fn total_command_count(&self) -> usize {
316 self.builtin_commands.len() + self.plugin_command_count()
317 }
318
319 pub fn find_by_name(&self, name: &str) -> Option<Command> {
321 {
323 let plugin_commands = self.plugin_commands.read().unwrap();
324 if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
325 return Some(cmd.clone());
326 }
327 }
328
329 self.builtin_commands
331 .iter()
332 .find(|c| c.name == name)
333 .cloned()
334 }
335}
336
337impl Default for CommandRegistry {
338 fn default() -> Self {
339 Self::new()
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::input::commands::CommandSource;
347 use crate::input::keybindings::Action;
348
349 #[test]
350 fn test_command_registry_creation() {
351 let registry = CommandRegistry::new();
352 assert!(registry.total_command_count() > 0); assert_eq!(registry.plugin_command_count(), 0); }
355
356 #[test]
357 fn test_register_command() {
358 let registry = CommandRegistry::new();
359
360 let custom_command = Command {
361 name: "Test Command".to_string(),
362 description: "A test command".to_string(),
363 action: Action::None,
364 contexts: vec![],
365 custom_contexts: vec![],
366 source: CommandSource::Builtin,
367 };
368
369 registry.register(custom_command.clone());
370 assert_eq!(registry.plugin_command_count(), 1);
371
372 let found = registry.find_by_name("Test Command");
373 assert!(found.is_some());
374 assert_eq!(found.unwrap().description, "A test command");
375 }
376
377 #[test]
378 fn test_unregister_command() {
379 let registry = CommandRegistry::new();
380
381 let custom_command = Command {
382 name: "Test Command".to_string(),
383 description: "A test command".to_string(),
384 action: Action::None,
385 contexts: vec![],
386 custom_contexts: vec![],
387 source: CommandSource::Builtin,
388 };
389
390 registry.register(custom_command);
391 assert_eq!(registry.plugin_command_count(), 1);
392
393 registry.unregister("Test Command");
394 assert_eq!(registry.plugin_command_count(), 0);
395 }
396
397 #[test]
398 fn test_register_replaces_existing() {
399 let registry = CommandRegistry::new();
400
401 let command1 = Command {
402 name: "Test Command".to_string(),
403 description: "First version".to_string(),
404 action: Action::None,
405 contexts: vec![],
406 custom_contexts: vec![],
407 source: CommandSource::Builtin,
408 };
409
410 let command2 = Command {
411 name: "Test Command".to_string(),
412 description: "Second version".to_string(),
413 action: Action::None,
414 contexts: vec![],
415 custom_contexts: vec![],
416 source: CommandSource::Builtin,
417 };
418
419 registry.register(command1);
420 assert_eq!(registry.plugin_command_count(), 1);
421
422 registry.register(command2);
423 assert_eq!(registry.plugin_command_count(), 1); let found = registry.find_by_name("Test Command").unwrap();
426 assert_eq!(found.description, "Second version");
427 }
428
429 #[test]
430 fn test_unregister_by_prefix() {
431 let registry = CommandRegistry::new();
432
433 registry.register(Command {
434 name: "Plugin A: Command 1".to_string(),
435 description: "".to_string(),
436 action: Action::None,
437 contexts: vec![],
438 custom_contexts: vec![],
439 source: CommandSource::Builtin,
440 });
441
442 registry.register(Command {
443 name: "Plugin A: Command 2".to_string(),
444 description: "".to_string(),
445 action: Action::None,
446 contexts: vec![],
447 custom_contexts: vec![],
448 source: CommandSource::Builtin,
449 });
450
451 registry.register(Command {
452 name: "Plugin B: Command".to_string(),
453 description: "".to_string(),
454 action: Action::None,
455 contexts: vec![],
456 custom_contexts: vec![],
457 source: CommandSource::Builtin,
458 });
459
460 assert_eq!(registry.plugin_command_count(), 3);
461
462 registry.unregister_by_prefix("Plugin A:");
463 assert_eq!(registry.plugin_command_count(), 1);
464
465 let remaining = registry.find_by_name("Plugin B: Command");
466 assert!(remaining.is_some());
467 }
468
469 #[test]
470 fn test_filter_commands() {
471 use crate::config::Config;
472 use crate::input::keybindings::KeybindingResolver;
473
474 let registry = CommandRegistry::new();
475 let config = Config::default();
476 let keybindings = KeybindingResolver::new(&config);
477
478 registry.register(Command {
479 name: "Test Save".to_string(),
480 description: "Test save command".to_string(),
481 action: Action::None,
482 contexts: vec![KeyContext::Normal],
483 custom_contexts: vec![],
484 source: CommandSource::Builtin,
485 });
486
487 let empty_contexts = std::collections::HashSet::new();
488 let results = registry.filter(
489 "save",
490 KeyContext::Normal,
491 &keybindings,
492 false,
493 &empty_contexts,
494 None,
495 true,
496 );
497 assert!(results.len() >= 2); let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
501 assert!(names.iter().any(|n| n.contains("Save")));
502 }
503
504 #[test]
505 fn test_context_filtering() {
506 use crate::config::Config;
507 use crate::input::keybindings::KeybindingResolver;
508
509 let registry = CommandRegistry::new();
510 let config = Config::default();
511 let keybindings = KeybindingResolver::new(&config);
512
513 registry.register(Command {
514 name: "Normal Only".to_string(),
515 description: "Available only in normal context".to_string(),
516 action: Action::None,
517 contexts: vec![KeyContext::Normal],
518 custom_contexts: vec![],
519 source: CommandSource::Builtin,
520 });
521
522 registry.register(Command {
523 name: "Popup Only".to_string(),
524 description: "Available only in popup context".to_string(),
525 action: Action::None,
526 contexts: vec![KeyContext::Popup],
527 custom_contexts: vec![],
528 source: CommandSource::Builtin,
529 });
530
531 let empty_contexts = std::collections::HashSet::new();
533 let results = registry.filter(
534 "",
535 KeyContext::Normal,
536 &keybindings,
537 false,
538 &empty_contexts,
539 None,
540 true,
541 );
542 let popup_only = results.iter().find(|s| s.text == "Popup Only");
543 assert!(popup_only.is_some());
544 assert!(popup_only.unwrap().disabled);
545
546 let results = registry.filter(
548 "",
549 KeyContext::Popup,
550 &keybindings,
551 false,
552 &empty_contexts,
553 None,
554 true,
555 );
556 let normal_only = results.iter().find(|s| s.text == "Normal Only");
557 assert!(normal_only.is_some());
558 assert!(normal_only.unwrap().disabled);
559 }
560
561 #[test]
562 fn test_get_all_merges_commands() {
563 let registry = CommandRegistry::new();
564 let initial_count = registry.total_command_count();
565
566 registry.register(Command {
567 name: "Custom 1".to_string(),
568 description: "".to_string(),
569 action: Action::None,
570 contexts: vec![],
571 custom_contexts: vec![],
572 source: CommandSource::Builtin,
573 });
574
575 registry.register(Command {
576 name: "Custom 2".to_string(),
577 description: "".to_string(),
578 action: Action::None,
579 contexts: vec![],
580 custom_contexts: vec![],
581 source: CommandSource::Builtin,
582 });
583
584 let all = registry.get_all();
585 assert_eq!(all.len(), initial_count + 2);
586 }
587
588 #[test]
589 fn test_plugin_command_overrides_builtin() {
590 let registry = CommandRegistry::new();
591
592 let builtin = registry.find_by_name("Save File");
594 assert!(builtin.is_some());
595 let original_desc = builtin.unwrap().description;
596
597 registry.register(Command {
599 name: "Save File".to_string(),
600 description: "Custom save implementation".to_string(),
601 action: Action::None,
602 contexts: vec![],
603 custom_contexts: vec![],
604 source: CommandSource::Builtin,
605 });
606
607 let custom = registry.find_by_name("Save File").unwrap();
609 assert_eq!(custom.description, "Custom save implementation");
610 assert_ne!(custom.description, original_desc);
611 }
612
613 #[test]
614 fn test_record_usage() {
615 let mut registry = CommandRegistry::new();
616
617 registry.record_usage("Save File");
618 assert_eq!(registry.history_position("Save File"), Some(0));
619
620 registry.record_usage("Open File");
621 assert_eq!(registry.history_position("Open File"), Some(0));
622 assert_eq!(registry.history_position("Save File"), Some(1));
623
624 registry.record_usage("Save File");
626 assert_eq!(registry.history_position("Save File"), Some(0));
627 assert_eq!(registry.history_position("Open File"), Some(1));
628 }
629
630 #[test]
631 fn test_history_sorting() {
632 use crate::config::Config;
633 use crate::input::keybindings::KeybindingResolver;
634
635 let mut registry = CommandRegistry::new();
636 let config = Config::default();
637 let keybindings = KeybindingResolver::new(&config);
638
639 registry.record_usage("Quit");
641 registry.record_usage("Save File");
642 registry.record_usage("Open File");
643
644 let empty_contexts = std::collections::HashSet::new();
646 let results = registry.filter(
647 "",
648 KeyContext::Normal,
649 &keybindings,
650 false,
651 &empty_contexts,
652 None,
653 true,
654 );
655
656 let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
658 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
659 let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
660
661 assert!(
663 open_pos < save_pos,
664 "Open File should come before Save File"
665 );
666 assert!(save_pos < quit_pos, "Save File should come before Quit");
667 }
668
669 #[test]
670 fn test_history_max_size() {
671 let mut registry = CommandRegistry::new();
672
673 for i in 0..60 {
675 registry.record_usage(&format!("Command {}", i));
676 }
677
678 assert_eq!(
680 registry.command_history.len(),
681 CommandRegistry::MAX_HISTORY_SIZE
682 );
683
684 assert_eq!(registry.history_position("Command 59"), Some(0));
686
687 assert_eq!(registry.history_position("Command 0"), None);
689 }
690
691 #[test]
692 fn test_unused_commands_alphabetical() {
693 use crate::config::Config;
694 use crate::input::keybindings::KeybindingResolver;
695
696 let mut registry = CommandRegistry::new();
697 let config = Config::default();
698 let keybindings = KeybindingResolver::new(&config);
699
700 registry.register(Command {
702 name: "Zebra Command".to_string(),
703 description: "".to_string(),
704 action: Action::None,
705 contexts: vec![],
706 custom_contexts: vec![],
707 source: CommandSource::Builtin,
708 });
709
710 registry.register(Command {
711 name: "Alpha Command".to_string(),
712 description: "".to_string(),
713 action: Action::None,
714 contexts: vec![],
715 custom_contexts: vec![],
716 source: CommandSource::Builtin,
717 });
718
719 registry.record_usage("Save File");
721
722 let empty_contexts = std::collections::HashSet::new();
723 let results = registry.filter(
724 "",
725 KeyContext::Normal,
726 &keybindings,
727 false,
728 &empty_contexts,
729 None,
730 true,
731 );
732
733 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
734 let alpha_pos = results
735 .iter()
736 .position(|s| s.text == "Alpha Command")
737 .unwrap();
738 let zebra_pos = results
739 .iter()
740 .position(|s| s.text == "Zebra Command")
741 .unwrap();
742
743 assert!(
745 save_pos < alpha_pos,
746 "Save File should come before Alpha Command"
747 );
748 assert!(
750 alpha_pos < zebra_pos,
751 "Alpha Command should come before Zebra Command"
752 );
753 }
754
755 #[test]
756 fn test_required_commands_exist() {
757 crate::i18n::set_locale("en");
760 let registry = CommandRegistry::new();
761
762 let required_commands = [
763 ("Show Completions", Action::LspCompletion),
765 ("Go to Definition", Action::LspGotoDefinition),
766 ("Show Hover Info", Action::LspHover),
767 ("Find References", Action::LspReferences),
768 ("Show Manual", Action::ShowHelp),
770 ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
771 ("Scroll Up", Action::ScrollUp),
773 ("Scroll Down", Action::ScrollDown),
774 ("Scroll Tabs Left", Action::ScrollTabsLeft),
775 ("Scroll Tabs Right", Action::ScrollTabsRight),
776 ("Smart Home", Action::SmartHome),
778 ("Delete Word Backward", Action::DeleteWordBackward),
780 ("Delete Word Forward", Action::DeleteWordForward),
781 ("Delete to End of Line", Action::DeleteToLineEnd),
782 ];
783
784 for (name, expected_action) in required_commands {
785 let cmd = registry.find_by_name(name);
786 assert!(
787 cmd.is_some(),
788 "Command '{}' should exist in command palette",
789 name
790 );
791 assert_eq!(
792 cmd.unwrap().action,
793 expected_action,
794 "Command '{}' should have action {:?}",
795 name,
796 expected_action
797 );
798 }
799 }
800}