diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index 93c357f51..9afde072a 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -122,6 +122,24 @@ impl PluginManager { .collect() } + /// Command names advertised by every alive, non-disabled plugin: each + /// ribbon tool's command id plus the manifest's `command_prefixes`. The + /// host merges these into command-line autocomplete so plugin commands are + /// discoverable by typing (#272). + pub fn command_names bool>(&self, is_disabled: F) -> Vec { + let mut out = Vec::new(); + for p in &self.plugins { + if is_disabled(p.process.id()) || !p.process.is_alive() { + continue; + } + for group in p.process.ribbon() { + out.append(&mut group.command_ids()); + } + out.extend(p.process.manifest().command_prefixes.iter().cloned()); + } + out + } + /// Begin asynchronous shutdown of every plugin process. /// /// Kills every child synchronously on the calling thread and moves the diff --git a/crates/ocs_plugin_api/src/ribbon/owned.rs b/crates/ocs_plugin_api/src/ribbon/owned.rs index 1ae624368..4e6f46cf4 100644 --- a/crates/ocs_plugin_api/src/ribbon/owned.rs +++ b/crates/ocs_plugin_api/src/ribbon/owned.rs @@ -58,6 +58,49 @@ pub struct OwnedRibbonGroup { pub tools: Vec, } +impl OwnedRibbonGroup { + /// Every command id a user could invoke from this group's tools. The host + /// feeds these into the command-line autocomplete so a loaded plugin's + /// commands are discoverable by typing, not just via ribbon buttons (#272). + pub fn command_ids(&self) -> Vec { + let mut out = Vec::new(); + for item in &self.tools { + collect_item_command_ids(item, &mut out); + } + out + } +} + +fn push_tool_command(tool: &OwnedToolDef, out: &mut Vec) { + if let ModuleEvent::Command(cmd) = &tool.event { + out.push(cmd.clone()); + } +} + +fn collect_item_command_ids(item: &OwnedRibbonItem, out: &mut Vec) { + match item { + OwnedRibbonItem::Tool(t) | OwnedRibbonItem::LargeTool(t) => push_tool_command(t, out), + OwnedRibbonItem::Dropdown { items, .. } | OwnedRibbonItem::LargeDropdown { items, .. } => { + // Each entry is (command id, label, icon). + out.extend(items.iter().map(|(id, _, _)| id.clone())); + } + OwnedRibbonItem::LayerComboGroup { row2, row3 } => { + for t in row2.iter().chain(row3.iter()) { + push_tool_command(t, out); + } + } + OwnedRibbonItem::PropertiesGroup { match_prop } => push_tool_command(match_prop, out), + OwnedRibbonItem::StyleComboGroup { rows, manager_cmd, .. } => { + for t in rows.iter().flatten() { + push_tool_command(t, out); + } + if let Some(cmd) = manager_cmd { + out.push(cmd.clone()); + } + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OwnedPluginManifest { pub id: String, @@ -446,4 +489,33 @@ mod tests { assert_eq!(cloned.title(), shared.title()); assert_eq!(shared.ribbon_groups().len(), cloned.ribbon_groups().len()); } + + #[test] + fn command_ids_covers_tools_and_dropdown_items() { + let group = OwnedRibbonGroup { + title: "Labels".to_string(), + tools: vec![ + OwnedRibbonItem::Tool(OwnedToolDef { + id: "ls_label".to_string(), + label: "Label".to_string(), + icon: OwnedIconKind::Glyph("L".to_string()), + event: ModuleEvent::Command("LS_LABEL".to_string()), + }), + OwnedRibbonItem::LargeDropdown { + id: "ls_menu".to_string(), + label: "More".to_string(), + icon: OwnedIconKind::Glyph("M".to_string()), + items: vec![( + "LS_AUTOLABEL".to_string(), + "Auto".to_string(), + OwnedIconKind::Glyph("A".to_string()), + )], + default: "LS_AUTOLABEL".to_string(), + }, + ], + }; + let ids = group.command_ids(); + assert!(ids.contains(&"LS_LABEL".to_string()), "got {ids:?}"); + assert!(ids.contains(&"LS_AUTOLABEL".to_string()), "got {ids:?}"); + } } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index cfac945c7..8085f75f4 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -148,12 +148,15 @@ impl OpenCADStudio { // command completes on Enter (`BAC` → `BACKGROUND`). The verb's own // input was already cleared, so rank against `cmd` directly. if allow_suggest { - if let Some(top) = crate::ui::command_line::ranked_matches(cmd) - .first() - .copied() + if let Some(top) = crate::ui::command_line::ranked_matches( + cmd, + &self.command_line.dynamic_commands, + ) + .into_iter() + .next() { if !top.eq_ignore_ascii_case(cmd) { - return self.dispatch_command_inner(top, false); + return self.dispatch_command_inner(&top, false); } } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index fa0453b4f..a6a9c1eb6 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -194,8 +194,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // rather than the partial text actually in the buffer. let i_tab = self.active_tab; if self.tabs[i_tab].active_cmd.is_none() { - if let Some(picked) = self.command_line.selected_suggestion() { - let cmd = picked.to_string(); + if let Some(cmd) = self.command_line.selected_suggestion() { self.command_line.input.clear(); self.command_line.autocomplete_cursor = None; return self.dispatch_command(&cmd); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index f433409b7..749334004 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -158,6 +158,11 @@ impl OpenCADStudio { let modules = crate::plugin::ribbon_modules_enabled(&self.disabled_plugins); self.ribbon.set_modules(modules); + // Refresh the command-line autocomplete pool so a newly enabled plugin's + // commands become typeable (and a disabled one's drop out). This runs on + // startup load, settings reload, and every enable/disable toggle (#272). + self.command_line.dynamic_commands = + crate::plugin::plugin_command_names(&self.disabled_plugins); } /// Snapshot of disabled plugin ids — lets the registry skip them while it diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 5964f7132..6e7832983 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -7,7 +7,7 @@ pub mod host; pub mod marketplace; pub mod registry; -pub use registry::{all_ribbon_modules, ribbon_modules_enabled}; +pub use registry::{all_ribbon_modules, plugin_command_names, ribbon_modules_enabled}; pub(crate) use registry::try_dispatch; /// Run a plugin entry point under a panic guard so a buggy external plugin diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 756405190..fed97df6d 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -31,6 +31,24 @@ pub fn ribbon_modules_enabled( core } +/// Command names contributed by loaded external plugins whose id is **not** in +/// `disabled` — every ribbon tool's command id plus manifest command prefixes. +/// The host merges these into command-line autocomplete so plugin commands are +/// discoverable by typing, not only via ribbon buttons (#272). +pub fn plugin_command_names(disabled: &rustc_hash::FxHashSet) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + { + return crate::plugin::external::with_manager(|manager| { + manager.command_names(|id| disabled.contains(id)) + }); + } + #[cfg(target_arch = "wasm32")] + { + let _ = disabled; + Vec::new() + } +} + /// Dispatch `cmd` to a loaded external plugin (skipping disabled ones). /// Returns true if one handled it. pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool { diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index c4f8c1d40..87af7f54d 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -44,6 +44,11 @@ pub struct CommandLine { /// `None` when the user hasn't yet started navigating with the /// arrow keys. Reset on every keystroke. pub autocomplete_cursor: Option, + /// Command names contributed by loaded plugins, refreshed whenever the + /// enabled-plugin set changes. Merged into autocomplete alongside the + /// compile-time command registry, so runtime plugin commands are typeable + /// and discoverable — not only reachable via ribbon buttons (#272). + pub dynamic_commands: Vec, /// The active command step's prompt, mirrored here so a step change /// can be detected and the pinned (non-fading) history line updated. step_prompt: Option, @@ -277,16 +282,16 @@ impl CommandLine { /// The command name the user has currently highlighted in the /// autocomplete popup, if any. - pub fn selected_suggestion(&self) -> Option<&'static str> { + pub fn selected_suggestion(&self) -> Option { let matches = self.autocomplete_matches(); self.autocomplete_cursor - .and_then(|i| matches.get(i).copied()) + .and_then(|i| matches.get(i).cloned()) } /// Autocomplete suggestions for the current input — see - /// [`ranked_matches`]. - pub fn autocomplete_matches(&self) -> Vec<&'static str> { - ranked_matches(self.input.trim()) + /// [`ranked_matches`]. Includes loaded plugins' commands (#272). + pub fn autocomplete_matches(&self) -> Vec { + ranked_matches(self.input.trim(), &self.dynamic_commands) } pub fn view<'a>( @@ -372,8 +377,8 @@ impl CommandLine { let mut col = column![].spacing(0).width(Length::Fill); for (idx, cmd) in matches.iter().enumerate() { let is_selected = cursor == idx; - let row = button(text(*cmd).size(11).color(CMD_COLOR)) - .on_press(Message::CommandSuggestionPick(cmd.to_string())) + let row = button(text(cmd.clone()).size(11).color(CMD_COLOR)) + .on_press(Message::CommandSuggestionPick(cmd.clone())) .width(Length::Fill) .padding([2, 8]) .style(move |_: &Theme, status| { @@ -564,23 +569,32 @@ impl CommandLine { /// Shared by the suggestion popup and the Enter-key closest-match fallback so /// both agree on the top suggestion. /// -/// Names come from `crate::command::all_registered_command_names()` which -/// collects every `inventory::submit!` block placed next to a `CadCommand` -/// impl — no central list to maintain. -pub fn ranked_matches(needle: &str) -> Vec<&'static str> { +/// Names come from `crate::command::all_registered_command_names()` — the +/// compile-time `inventory` registry — merged with `dynamic`, the command names +/// contributed by loaded plugins (runtime, so they can't be `&'static`; see +/// #272). Returns owned strings to carry both sources. +pub fn ranked_matches(needle: &str, dynamic: &[String]) -> Vec { let needle = needle.trim().to_uppercase(); if needle.is_empty() { return Vec::new(); } - let mut matches: Vec<&'static str> = crate::command::all_registered_command_names() + let mut matches: Vec = crate::command::all_registered_command_names() .into_iter() + .map(|cmd| cmd.to_string()) + // Plugin names are uppercased to match the built-ins and the needle, + // so ranking and display stay consistent across both sources. + .chain(dynamic.iter().map(|cmd| cmd.to_uppercase())) .filter(|cmd| cmd.contains(&needle)) .collect(); matches.sort(); matches.dedup(); // Prefix matches rank above mid-string ones, then alphabetical so the // order is stable as the user keeps typing. - matches.sort_by_key(|cmd| (!cmd.starts_with(&needle), *cmd)); + matches.sort_by(|a, b| { + (!a.starts_with(&needle)) + .cmp(&!b.starts_with(&needle)) + .then_with(|| a.cmp(b)) + }); matches.truncate(AUTOCOMPLETE_LIMIT); matches } @@ -671,3 +685,27 @@ const INFO_COLOR: Color = Color { a: 1.0, }; +#[cfg(test)] +mod tests { + use super::ranked_matches; + + #[test] + fn dynamic_plugin_commands_surface_in_autocomplete() { + // No built-in command contains the plugin prefix, so an empty pool + // yields nothing — reproducing the #272 bug's blind autocomplete. + assert!(ranked_matches("LS_", &[]).is_empty()); + // A loaded plugin's commands become typeable, case-insensitively, and + // the prefix substring surfaces every one of them. + let pool = vec!["LS_LABEL".to_string(), "ls_autolabel".to_string()]; + let m = ranked_matches("LS_", &pool); + assert!(m.contains(&"LS_LABEL".to_string()), "got {m:?}"); + assert!(m.contains(&"LS_AUTOLABEL".to_string()), "got {m:?}"); + } + + #[test] + fn builtin_commands_still_match_with_an_empty_pool() { + let m = ranked_matches("LINE", &[]); + assert!(m.iter().any(|c| c == "LINE"), "got {m:?}"); + } +} +