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    ///
165    /// `has_lsp_config` indicates whether the active buffer's language has an LSP server
166    /// configured. When false, LSP start/restart/toggle commands are disabled.
167    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        // Helper function to check if command should be visible (custom context check)
180        // Commands with unmet custom contexts are completely hidden, not just disabled
181        // A custom context is satisfied if:
182        // 1. It's in the global active_custom_contexts set, OR
183        // 2. It matches the focused buffer's mode (for buffer-scoped commands)
184        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        // Helper function to check if command is available in current context
193        let is_available = |cmd: &Command| -> bool {
194            // Global commands are always available
195            if cmd.contexts.contains(&KeyContext::Global) {
196                return true;
197            }
198
199            // Check built-in contexts
200            cmd.contexts.is_empty() || cmd.contexts.contains(&current_context)
201        };
202
203        // Helper to create a suggestion from a command
204        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                // Disable LSP start/restart/toggle commands when no LSP is configured
211                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        // First, try to match by name only
231        // Commands with unmet custom contexts are completely hidden
232        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 no name matches found, try description matching as a fallback
253        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                        // Description matches get reduced score
263                        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        // Sort by:
277        // 1. Disabled status (enabled first)
278        // 2. Fuzzy match score (higher is better) - only when query is not empty
279        // 3. History position (recent first, then never-used alphabetically)
280        let has_query = !query.is_empty();
281        suggestions.sort_by(|(a, a_hist, a_score), (b, b_hist, b_score)| {
282            // First sort by disabled status
283            match a.disabled.cmp(&b.disabled) {
284                std::cmp::Ordering::Equal => {}
285                other => return other,
286            }
287
288            // When there's a query, sort by fuzzy score (higher is better)
289            if has_query {
290                match b_score.cmp(a_score) {
291                    std::cmp::Ordering::Equal => {}
292                    other => return other,
293                }
294            }
295
296            // Then sort by history position (lower = more recent = better)
297            match (a_hist, b_hist) {
298                (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
299                (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history
300                (None, Some(_)) => std::cmp::Ordering::Greater,
301                (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands
302            }
303        });
304
305        // Extract just the suggestions
306        suggestions.into_iter().map(|(s, _, _)| s).collect()
307    }
308
309    /// Get count of registered plugin commands
310    pub fn plugin_command_count(&self) -> usize {
311        self.plugin_commands.read().unwrap().len()
312    }
313
314    /// Get count of total commands (built-in + plugin)
315    pub fn total_command_count(&self) -> usize {
316        self.builtin_commands.len() + self.plugin_command_count()
317    }
318
319    /// Find a command by exact name match
320    pub fn find_by_name(&self, name: &str) -> Option<Command> {
321        // Check plugin commands first (they can override built-in)
322        {
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        // Then check built-in commands
330        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); // Has built-in commands
353        assert_eq!(registry.plugin_command_count(), 0); // No plugin commands yet
354    }
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); // Still just one
424
425        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); // At least "Save File" + "Test Save"
498
499        // Check that both built-in and custom commands appear
500        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        // In normal context, "Popup Only" should be disabled
532        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        // In popup context, "Normal Only" should be disabled
547        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        // Check a built-in command exists
593        let builtin = registry.find_by_name("Save File");
594        assert!(builtin.is_some());
595        let original_desc = builtin.unwrap().description;
596
597        // Override it with a plugin command
598        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        // Should now find the custom version
608        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        // Using Save File again should move it to front
625        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        // Record some commands
640        registry.record_usage("Quit");
641        registry.record_usage("Save File");
642        registry.record_usage("Open File");
643
644        // Filter with empty query should return history-sorted results
645        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        // Find positions of our test commands in results
657        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        // Most recently used should be first
662        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        // Add more than MAX_HISTORY_SIZE commands
674        for i in 0..60 {
675            registry.record_usage(&format!("Command {}", i));
676        }
677
678        // Should be trimmed to MAX_HISTORY_SIZE
679        assert_eq!(
680            registry.command_history.len(),
681            CommandRegistry::MAX_HISTORY_SIZE
682        );
683
684        // Most recent should still be at front
685        assert_eq!(registry.history_position("Command 59"), Some(0));
686
687        // Oldest should be trimmed
688        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        // Register some custom commands (never used)
701        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        // Use one built-in command
720        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        // Used command should be first
744        assert!(
745            save_pos < alpha_pos,
746            "Save File should come before Alpha Command"
747        );
748        // Unused commands should be alphabetical
749        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        // This test ensures that all required command palette entries exist.
758        // If this test fails, it means a command was removed or renamed.
759        crate::i18n::set_locale("en");
760        let registry = CommandRegistry::new();
761
762        let required_commands = [
763            // LSP commands
764            ("Show Completions", Action::LspCompletion),
765            ("Go to Definition", Action::LspGotoDefinition),
766            ("Show Hover Info", Action::LspHover),
767            ("Find References", Action::LspReferences),
768            // Help commands
769            ("Show Manual", Action::ShowHelp),
770            ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
771            // Scroll commands
772            ("Scroll Up", Action::ScrollUp),
773            ("Scroll Down", Action::ScrollDown),
774            ("Scroll Tabs Left", Action::ScrollTabsLeft),
775            ("Scroll Tabs Right", Action::ScrollTabsRight),
776            // Navigation commands
777            ("Smart Home", Action::SmartHome),
778            // Delete commands
779            ("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}