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