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(
165 &self,
166 query: &str,
167 current_context: KeyContext,
168 keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
169 selection_active: bool,
170 active_custom_contexts: &std::collections::HashSet<String>,
171 active_buffer_mode: Option<&str>,
172 ) -> Vec<Suggestion> {
173 let commands = self.get_all();
174
175 let is_visible = |cmd: &Command| -> bool {
181 cmd.custom_contexts.is_empty()
182 || cmd.custom_contexts.iter().all(|ctx| {
183 active_custom_contexts.contains(ctx)
184 || active_buffer_mode.is_some_and(|mode| mode == ctx)
185 })
186 };
187
188 let is_available = |cmd: &Command| -> bool {
190 if cmd.contexts.contains(&KeyContext::Global) {
192 return true;
193 }
194
195 cmd.contexts.is_empty() || cmd.contexts.contains(¤t_context)
197 };
198
199 let make_suggestion =
201 |cmd: &Command, score: i32, localized_name: String, localized_desc: String| {
202 let mut available = is_available(cmd);
203 if cmd.action == Action::FindInSelection && !selection_active {
204 available = false;
205 }
206 let keybinding =
207 keybinding_resolver.get_keybinding_for_action(&cmd.action, current_context);
208 let history_pos = self.history_position(&cmd.name);
209
210 let suggestion = Suggestion::with_source(
211 localized_name,
212 Some(localized_desc),
213 !available,
214 keybinding,
215 Some(cmd.source.clone()),
216 );
217 (suggestion, history_pos, score)
218 };
219
220 let mut suggestions: Vec<(Suggestion, Option<usize>, i32)> = commands
223 .iter()
224 .filter(|cmd| is_visible(cmd))
225 .filter_map(|cmd| {
226 let localized_name = cmd.get_localized_name();
227 let name_result = fuzzy_match(query, &localized_name);
228 if name_result.matched {
229 let localized_desc = cmd.get_localized_description();
230 Some(make_suggestion(
231 cmd,
232 name_result.score,
233 localized_name,
234 localized_desc,
235 ))
236 } else {
237 None
238 }
239 })
240 .collect();
241
242 if suggestions.is_empty() && !query.is_empty() {
244 suggestions = commands
245 .iter()
246 .filter(|cmd| is_visible(cmd))
247 .filter_map(|cmd| {
248 let localized_desc = cmd.get_localized_description();
249 let desc_result = fuzzy_match(query, &localized_desc);
250 if desc_result.matched {
251 let localized_name = cmd.get_localized_name();
252 Some(make_suggestion(
254 cmd,
255 desc_result.score.saturating_sub(50),
256 localized_name,
257 localized_desc,
258 ))
259 } else {
260 None
261 }
262 })
263 .collect();
264 }
265
266 let has_query = !query.is_empty();
271 suggestions.sort_by(|(a, a_hist, a_score), (b, b_hist, b_score)| {
272 match a.disabled.cmp(&b.disabled) {
274 std::cmp::Ordering::Equal => {}
275 other => return other,
276 }
277
278 if has_query {
280 match b_score.cmp(a_score) {
281 std::cmp::Ordering::Equal => {}
282 other => return other,
283 }
284 }
285
286 match (a_hist, b_hist) {
288 (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
289 (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater,
291 (None, None) => a.text.cmp(&b.text), }
293 });
294
295 suggestions.into_iter().map(|(s, _, _)| s).collect()
297 }
298
299 pub fn plugin_command_count(&self) -> usize {
301 self.plugin_commands.read().unwrap().len()
302 }
303
304 pub fn total_command_count(&self) -> usize {
306 self.builtin_commands.len() + self.plugin_command_count()
307 }
308
309 pub fn find_by_name(&self, name: &str) -> Option<Command> {
311 {
313 let plugin_commands = self.plugin_commands.read().unwrap();
314 if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
315 return Some(cmd.clone());
316 }
317 }
318
319 self.builtin_commands
321 .iter()
322 .find(|c| c.name == name)
323 .cloned()
324 }
325}
326
327impl Default for CommandRegistry {
328 fn default() -> Self {
329 Self::new()
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::input::commands::CommandSource;
337 use crate::input::keybindings::Action;
338
339 #[test]
340 fn test_command_registry_creation() {
341 let registry = CommandRegistry::new();
342 assert!(registry.total_command_count() > 0); assert_eq!(registry.plugin_command_count(), 0); }
345
346 #[test]
347 fn test_register_command() {
348 let registry = CommandRegistry::new();
349
350 let custom_command = Command {
351 name: "Test Command".to_string(),
352 description: "A test command".to_string(),
353 action: Action::None,
354 contexts: vec![],
355 custom_contexts: vec![],
356 source: CommandSource::Builtin,
357 };
358
359 registry.register(custom_command.clone());
360 assert_eq!(registry.plugin_command_count(), 1);
361
362 let found = registry.find_by_name("Test Command");
363 assert!(found.is_some());
364 assert_eq!(found.unwrap().description, "A test command");
365 }
366
367 #[test]
368 fn test_unregister_command() {
369 let registry = CommandRegistry::new();
370
371 let custom_command = Command {
372 name: "Test Command".to_string(),
373 description: "A test command".to_string(),
374 action: Action::None,
375 contexts: vec![],
376 custom_contexts: vec![],
377 source: CommandSource::Builtin,
378 };
379
380 registry.register(custom_command);
381 assert_eq!(registry.plugin_command_count(), 1);
382
383 registry.unregister("Test Command");
384 assert_eq!(registry.plugin_command_count(), 0);
385 }
386
387 #[test]
388 fn test_register_replaces_existing() {
389 let registry = CommandRegistry::new();
390
391 let command1 = Command {
392 name: "Test Command".to_string(),
393 description: "First version".to_string(),
394 action: Action::None,
395 contexts: vec![],
396 custom_contexts: vec![],
397 source: CommandSource::Builtin,
398 };
399
400 let command2 = Command {
401 name: "Test Command".to_string(),
402 description: "Second version".to_string(),
403 action: Action::None,
404 contexts: vec![],
405 custom_contexts: vec![],
406 source: CommandSource::Builtin,
407 };
408
409 registry.register(command1);
410 assert_eq!(registry.plugin_command_count(), 1);
411
412 registry.register(command2);
413 assert_eq!(registry.plugin_command_count(), 1); let found = registry.find_by_name("Test Command").unwrap();
416 assert_eq!(found.description, "Second version");
417 }
418
419 #[test]
420 fn test_unregister_by_prefix() {
421 let registry = CommandRegistry::new();
422
423 registry.register(Command {
424 name: "Plugin A: Command 1".to_string(),
425 description: "".to_string(),
426 action: Action::None,
427 contexts: vec![],
428 custom_contexts: vec![],
429 source: CommandSource::Builtin,
430 });
431
432 registry.register(Command {
433 name: "Plugin A: Command 2".to_string(),
434 description: "".to_string(),
435 action: Action::None,
436 contexts: vec![],
437 custom_contexts: vec![],
438 source: CommandSource::Builtin,
439 });
440
441 registry.register(Command {
442 name: "Plugin B: Command".to_string(),
443 description: "".to_string(),
444 action: Action::None,
445 contexts: vec![],
446 custom_contexts: vec![],
447 source: CommandSource::Builtin,
448 });
449
450 assert_eq!(registry.plugin_command_count(), 3);
451
452 registry.unregister_by_prefix("Plugin A:");
453 assert_eq!(registry.plugin_command_count(), 1);
454
455 let remaining = registry.find_by_name("Plugin B: Command");
456 assert!(remaining.is_some());
457 }
458
459 #[test]
460 fn test_filter_commands() {
461 use crate::config::Config;
462 use crate::input::keybindings::KeybindingResolver;
463
464 let registry = CommandRegistry::new();
465 let config = Config::default();
466 let keybindings = KeybindingResolver::new(&config);
467
468 registry.register(Command {
469 name: "Test Save".to_string(),
470 description: "Test save command".to_string(),
471 action: Action::None,
472 contexts: vec![KeyContext::Normal],
473 custom_contexts: vec![],
474 source: CommandSource::Builtin,
475 });
476
477 let empty_contexts = std::collections::HashSet::new();
478 let results = registry.filter(
479 "save",
480 KeyContext::Normal,
481 &keybindings,
482 false,
483 &empty_contexts,
484 None,
485 );
486 assert!(results.len() >= 2); let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
490 assert!(names.iter().any(|n| n.contains("Save")));
491 }
492
493 #[test]
494 fn test_context_filtering() {
495 use crate::config::Config;
496 use crate::input::keybindings::KeybindingResolver;
497
498 let registry = CommandRegistry::new();
499 let config = Config::default();
500 let keybindings = KeybindingResolver::new(&config);
501
502 registry.register(Command {
503 name: "Normal Only".to_string(),
504 description: "Available only in normal context".to_string(),
505 action: Action::None,
506 contexts: vec![KeyContext::Normal],
507 custom_contexts: vec![],
508 source: CommandSource::Builtin,
509 });
510
511 registry.register(Command {
512 name: "Popup Only".to_string(),
513 description: "Available only in popup context".to_string(),
514 action: Action::None,
515 contexts: vec![KeyContext::Popup],
516 custom_contexts: vec![],
517 source: CommandSource::Builtin,
518 });
519
520 let empty_contexts = std::collections::HashSet::new();
522 let results = registry.filter(
523 "",
524 KeyContext::Normal,
525 &keybindings,
526 false,
527 &empty_contexts,
528 None,
529 );
530 let popup_only = results.iter().find(|s| s.text == "Popup Only");
531 assert!(popup_only.is_some());
532 assert!(popup_only.unwrap().disabled);
533
534 let results = registry.filter(
536 "",
537 KeyContext::Popup,
538 &keybindings,
539 false,
540 &empty_contexts,
541 None,
542 );
543 let normal_only = results.iter().find(|s| s.text == "Normal Only");
544 assert!(normal_only.is_some());
545 assert!(normal_only.unwrap().disabled);
546 }
547
548 #[test]
549 fn test_get_all_merges_commands() {
550 let registry = CommandRegistry::new();
551 let initial_count = registry.total_command_count();
552
553 registry.register(Command {
554 name: "Custom 1".to_string(),
555 description: "".to_string(),
556 action: Action::None,
557 contexts: vec![],
558 custom_contexts: vec![],
559 source: CommandSource::Builtin,
560 });
561
562 registry.register(Command {
563 name: "Custom 2".to_string(),
564 description: "".to_string(),
565 action: Action::None,
566 contexts: vec![],
567 custom_contexts: vec![],
568 source: CommandSource::Builtin,
569 });
570
571 let all = registry.get_all();
572 assert_eq!(all.len(), initial_count + 2);
573 }
574
575 #[test]
576 fn test_plugin_command_overrides_builtin() {
577 let registry = CommandRegistry::new();
578
579 let builtin = registry.find_by_name("Save File");
581 assert!(builtin.is_some());
582 let original_desc = builtin.unwrap().description;
583
584 registry.register(Command {
586 name: "Save File".to_string(),
587 description: "Custom save implementation".to_string(),
588 action: Action::None,
589 contexts: vec![],
590 custom_contexts: vec![],
591 source: CommandSource::Builtin,
592 });
593
594 let custom = registry.find_by_name("Save File").unwrap();
596 assert_eq!(custom.description, "Custom save implementation");
597 assert_ne!(custom.description, original_desc);
598 }
599
600 #[test]
601 fn test_record_usage() {
602 let mut registry = CommandRegistry::new();
603
604 registry.record_usage("Save File");
605 assert_eq!(registry.history_position("Save File"), Some(0));
606
607 registry.record_usage("Open File");
608 assert_eq!(registry.history_position("Open File"), Some(0));
609 assert_eq!(registry.history_position("Save File"), Some(1));
610
611 registry.record_usage("Save File");
613 assert_eq!(registry.history_position("Save File"), Some(0));
614 assert_eq!(registry.history_position("Open File"), Some(1));
615 }
616
617 #[test]
618 fn test_history_sorting() {
619 use crate::config::Config;
620 use crate::input::keybindings::KeybindingResolver;
621
622 let mut registry = CommandRegistry::new();
623 let config = Config::default();
624 let keybindings = KeybindingResolver::new(&config);
625
626 registry.record_usage("Quit");
628 registry.record_usage("Save File");
629 registry.record_usage("Open File");
630
631 let empty_contexts = std::collections::HashSet::new();
633 let results = registry.filter(
634 "",
635 KeyContext::Normal,
636 &keybindings,
637 false,
638 &empty_contexts,
639 None,
640 );
641
642 let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
644 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
645 let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
646
647 assert!(
649 open_pos < save_pos,
650 "Open File should come before Save File"
651 );
652 assert!(save_pos < quit_pos, "Save File should come before Quit");
653 }
654
655 #[test]
656 fn test_history_max_size() {
657 let mut registry = CommandRegistry::new();
658
659 for i in 0..60 {
661 registry.record_usage(&format!("Command {}", i));
662 }
663
664 assert_eq!(
666 registry.command_history.len(),
667 CommandRegistry::MAX_HISTORY_SIZE
668 );
669
670 assert_eq!(registry.history_position("Command 59"), Some(0));
672
673 assert_eq!(registry.history_position("Command 0"), None);
675 }
676
677 #[test]
678 fn test_unused_commands_alphabetical() {
679 use crate::config::Config;
680 use crate::input::keybindings::KeybindingResolver;
681
682 let mut registry = CommandRegistry::new();
683 let config = Config::default();
684 let keybindings = KeybindingResolver::new(&config);
685
686 registry.register(Command {
688 name: "Zebra Command".to_string(),
689 description: "".to_string(),
690 action: Action::None,
691 contexts: vec![],
692 custom_contexts: vec![],
693 source: CommandSource::Builtin,
694 });
695
696 registry.register(Command {
697 name: "Alpha Command".to_string(),
698 description: "".to_string(),
699 action: Action::None,
700 contexts: vec![],
701 custom_contexts: vec![],
702 source: CommandSource::Builtin,
703 });
704
705 registry.record_usage("Save File");
707
708 let empty_contexts = std::collections::HashSet::new();
709 let results = registry.filter(
710 "",
711 KeyContext::Normal,
712 &keybindings,
713 false,
714 &empty_contexts,
715 None,
716 );
717
718 let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
719 let alpha_pos = results
720 .iter()
721 .position(|s| s.text == "Alpha Command")
722 .unwrap();
723 let zebra_pos = results
724 .iter()
725 .position(|s| s.text == "Zebra Command")
726 .unwrap();
727
728 assert!(
730 save_pos < alpha_pos,
731 "Save File should come before Alpha Command"
732 );
733 assert!(
735 alpha_pos < zebra_pos,
736 "Alpha Command should come before Zebra Command"
737 );
738 }
739
740 #[test]
741 fn test_required_commands_exist() {
742 crate::i18n::set_locale("en");
745 let registry = CommandRegistry::new();
746
747 let required_commands = [
748 ("Show Completions", Action::LspCompletion),
750 ("Go to Definition", Action::LspGotoDefinition),
751 ("Show Hover Info", Action::LspHover),
752 ("Find References", Action::LspReferences),
753 ("Show Manual", Action::ShowHelp),
755 ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
756 ("Scroll Up", Action::ScrollUp),
758 ("Scroll Down", Action::ScrollDown),
759 ("Scroll Tabs Left", Action::ScrollTabsLeft),
760 ("Scroll Tabs Right", Action::ScrollTabsRight),
761 ("Smart Home", Action::SmartHome),
763 ("Delete Word Backward", Action::DeleteWordBackward),
765 ("Delete Word Forward", Action::DeleteWordForward),
766 ("Delete to End of Line", Action::DeleteToLineEnd),
767 ];
768
769 for (name, expected_action) in required_commands {
770 let cmd = registry.find_by_name(name);
771 assert!(
772 cmd.is_some(),
773 "Command '{}' should exist in command palette",
774 name
775 );
776 assert_eq!(
777 cmd.unwrap().action,
778 expected_action,
779 "Command '{}' should have action {:?}",
780 name,
781 expected_action
782 );
783 }
784 }
785}