Skip to main content

fresh/input/
command_registry.rs

1//! Dynamic command registry for plugins and extensions
2//!
3//! This module allows plugins to register custom commands dynamically
4//! while maintaining the built-in command set.
5
6use 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
12/// Registry for managing editor commands
13///
14/// Supports both built-in commands and dynamically registered plugin commands.
15/// Thread-safe for use across multiple threads (e.g., from async tasks).
16pub struct CommandRegistry {
17    /// Built-in commands (loaded once at startup)
18    builtin_commands: Vec<Command>,
19
20    /// Plugin-registered commands (dynamically added/removed)
21    plugin_commands: Arc<RwLock<Vec<Command>>>,
22
23    /// Command usage history (most recent first)
24    /// Used to sort command palette suggestions by recency
25    command_history: Vec<String>,
26}
27
28impl CommandRegistry {
29    /// Maximum number of commands to keep in history
30    const MAX_HISTORY_SIZE: usize = 50;
31
32    /// Create a new command registry with built-in commands
33    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    /// Refresh built-in commands (e.g. after locale change)
42    pub fn refresh_builtin_commands(&mut self) {
43        self.builtin_commands = get_all_commands();
44    }
45
46    /// Record that a command was used (for history/sorting)
47    ///
48    /// This moves the command to the front of the history list.
49    /// Recently used commands appear first in suggestions.
50    pub fn record_usage(&mut self, command_name: &str) {
51        // Remove existing entry if present
52        self.command_history.retain(|name| name != command_name);
53
54        // Add to front (most recent)
55        self.command_history.insert(0, command_name.to_string());
56
57        // Trim to max size
58        if self.command_history.len() > Self::MAX_HISTORY_SIZE {
59            self.command_history.truncate(Self::MAX_HISTORY_SIZE);
60        }
61    }
62
63    /// Get the position of a command in history (0 = most recent)
64    /// Returns None if command is not in history
65    fn history_position(&self, command_name: &str) -> Option<usize> {
66        self.command_history
67            .iter()
68            .position(|name| name == command_name)
69    }
70
71    /// Register a new command (typically from a plugin)
72    ///
73    /// If a command with the same name already exists, it will be replaced.
74    /// This allows plugins to override built-in commands.
75    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        // Remove existing command with same name
84        commands.retain(|c| c.name != command.name);
85
86        // Add new command
87        commands.push(command);
88        tracing::debug!(
89            "CommandRegistry::register: plugin_commands now has {} items",
90            commands.len()
91        );
92    }
93
94    /// Unregister a command by name
95    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    /// Unregister all commands whose name starts with a prefix
101    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    /// Unregister all commands registered by a specific plugin
107    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    /// Get all commands (built-in + plugin)
128    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        // Debug: check if vi_mode_toggle is in plugin commands
135        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    /// Filter commands by fuzzy matching query with context awareness
159    ///
160    /// When query is empty, commands are sorted by recency (most recently used first).
161    /// When query is not empty, commands are sorted by match quality (fzf-style scoring)
162    /// with recency as tiebreaker for equal scores.
163    /// Disabled commands always appear after enabled ones.
164    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        // Helper function to check if command should be visible (custom context check)
176        // Commands with unmet custom contexts are completely hidden, not just disabled
177        // A custom context is satisfied if:
178        // 1. It's in the global active_custom_contexts set, OR
179        // 2. It matches the focused buffer's mode (for buffer-scoped commands)
180        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        // Helper function to check if command is available in current context
189        let is_available = |cmd: &Command| -> bool {
190            // Global commands are always available
191            if cmd.contexts.contains(&KeyContext::Global) {
192                return true;
193            }
194
195            // Check built-in contexts
196            cmd.contexts.is_empty() || cmd.contexts.contains(&current_context)
197        };
198
199        // Helper to create a suggestion from a command
200        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        // First, try to match by name only
221        // Commands with unmet custom contexts are completely hidden
222        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 no name matches found, try description matching as a fallback
243        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                        // Description matches get reduced score
253                        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        // Sort by:
267        // 1. Disabled status (enabled first)
268        // 2. Fuzzy match score (higher is better) - only when query is not empty
269        // 3. History position (recent first, then never-used alphabetically)
270        let has_query = !query.is_empty();
271        suggestions.sort_by(|(a, a_hist, a_score), (b, b_hist, b_score)| {
272            // First sort by disabled status
273            match a.disabled.cmp(&b.disabled) {
274                std::cmp::Ordering::Equal => {}
275                other => return other,
276            }
277
278            // When there's a query, sort by fuzzy score (higher is better)
279            if has_query {
280                match b_score.cmp(a_score) {
281                    std::cmp::Ordering::Equal => {}
282                    other => return other,
283                }
284            }
285
286            // Then sort by history position (lower = more recent = better)
287            match (a_hist, b_hist) {
288                (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
289                (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history
290                (None, Some(_)) => std::cmp::Ordering::Greater,
291                (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands
292            }
293        });
294
295        // Extract just the suggestions
296        suggestions.into_iter().map(|(s, _, _)| s).collect()
297    }
298
299    /// Get count of registered plugin commands
300    pub fn plugin_command_count(&self) -> usize {
301        self.plugin_commands.read().unwrap().len()
302    }
303
304    /// Get count of total commands (built-in + plugin)
305    pub fn total_command_count(&self) -> usize {
306        self.builtin_commands.len() + self.plugin_command_count()
307    }
308
309    /// Find a command by exact name match
310    pub fn find_by_name(&self, name: &str) -> Option<Command> {
311        // Check plugin commands first (they can override built-in)
312        {
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        // Then check built-in commands
320        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); // Has built-in commands
343        assert_eq!(registry.plugin_command_count(), 0); // No plugin commands yet
344    }
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); // Still just one
414
415        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); // At least "Save File" + "Test Save"
487
488        // Check that both built-in and custom commands appear
489        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        // In normal context, "Popup Only" should be disabled
521        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        // In popup context, "Normal Only" should be disabled
535        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        // Check a built-in command exists
580        let builtin = registry.find_by_name("Save File");
581        assert!(builtin.is_some());
582        let original_desc = builtin.unwrap().description;
583
584        // Override it with a plugin command
585        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        // Should now find the custom version
595        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        // Using Save File again should move it to front
612        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        // Record some commands
627        registry.record_usage("Quit");
628        registry.record_usage("Save File");
629        registry.record_usage("Open File");
630
631        // Filter with empty query should return history-sorted results
632        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        // Find positions of our test commands in results
643        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        // Most recently used should be first
648        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        // Add more than MAX_HISTORY_SIZE commands
660        for i in 0..60 {
661            registry.record_usage(&format!("Command {}", i));
662        }
663
664        // Should be trimmed to MAX_HISTORY_SIZE
665        assert_eq!(
666            registry.command_history.len(),
667            CommandRegistry::MAX_HISTORY_SIZE
668        );
669
670        // Most recent should still be at front
671        assert_eq!(registry.history_position("Command 59"), Some(0));
672
673        // Oldest should be trimmed
674        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        // Register some custom commands (never used)
687        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        // Use one built-in command
706        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        // Used command should be first
729        assert!(
730            save_pos < alpha_pos,
731            "Save File should come before Alpha Command"
732        );
733        // Unused commands should be alphabetical
734        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        // This test ensures that all required command palette entries exist.
743        // If this test fails, it means a command was removed or renamed.
744        crate::i18n::set_locale("en");
745        let registry = CommandRegistry::new();
746
747        let required_commands = [
748            // LSP commands
749            ("Show Completions", Action::LspCompletion),
750            ("Go to Definition", Action::LspGotoDefinition),
751            ("Show Hover Info", Action::LspHover),
752            ("Find References", Action::LspReferences),
753            // Help commands
754            ("Show Manual", Action::ShowHelp),
755            ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
756            // Scroll commands
757            ("Scroll Up", Action::ScrollUp),
758            ("Scroll Down", Action::ScrollDown),
759            ("Scroll Tabs Left", Action::ScrollTabsLeft),
760            ("Scroll Tabs Right", Action::ScrollTabsRight),
761            // Navigation commands
762            ("Smart Home", Action::SmartHome),
763            // Delete commands
764            ("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}