feat(plugin): surface loaded plugin commands in command-line autocomplete
Autocomplete suggestions came only from all_registered_command_names() — the compile-time inventory registry returning &'static str — so runtime-loaded plugin commands could never appear. Typing a plugin's command prefix (e.g. `LS_`) showed nothing even with the plugin loaded and its ribbon tab active; dispatch worked, only discovery was blind. Collect each enabled plugin's ribbon ToolDef command ids plus its manifest command_prefixes into a dynamic candidate pool (OwnedRibbonGroup::command_ids → PluginManager::command_names → plugin_command_names), refreshed in rebuild_ribbon_modules on startup load, settings reload, and every enable/disable toggle. ranked_matches now merges this pool with the static registry and returns owned strings. A plugin.toml `commands = [...]` list for sub-verbs with no ribbon button (e.g. LS_AUTOLABEL) stays a follow-up — it's a plugin-API surface change. Closes #272 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
60872da62a
commit
77c0c5a9d0
8 changed files with 173 additions and 20 deletions
|
|
@ -122,6 +122,24 @@ impl PluginManager {
|
||||||
.collect()
|
.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<F: Fn(&str) -> bool>(&self, is_disabled: F) -> Vec<String> {
|
||||||
|
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.
|
/// Begin asynchronous shutdown of every plugin process.
|
||||||
///
|
///
|
||||||
/// Kills every child synchronously on the calling thread and moves the
|
/// Kills every child synchronously on the calling thread and moves the
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,49 @@ pub struct OwnedRibbonGroup {
|
||||||
pub tools: Vec<OwnedRibbonItem>,
|
pub tools: Vec<OwnedRibbonItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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<String>) {
|
||||||
|
if let ModuleEvent::Command(cmd) = &tool.event {
|
||||||
|
out.push(cmd.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_item_command_ids(item: &OwnedRibbonItem, out: &mut Vec<String>) {
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OwnedPluginManifest {
|
pub struct OwnedPluginManifest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -446,4 +489,33 @@ mod tests {
|
||||||
assert_eq!(cloned.title(), shared.title());
|
assert_eq!(cloned.title(), shared.title());
|
||||||
assert_eq!(shared.ribbon_groups().len(), cloned.ribbon_groups().len());
|
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:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,12 +148,15 @@ impl OpenCADStudio {
|
||||||
// command completes on Enter (`BAC` → `BACKGROUND`). The verb's own
|
// command completes on Enter (`BAC` → `BACKGROUND`). The verb's own
|
||||||
// input was already cleared, so rank against `cmd` directly.
|
// input was already cleared, so rank against `cmd` directly.
|
||||||
if allow_suggest {
|
if allow_suggest {
|
||||||
if let Some(top) = crate::ui::command_line::ranked_matches(cmd)
|
if let Some(top) = crate::ui::command_line::ranked_matches(
|
||||||
.first()
|
cmd,
|
||||||
.copied()
|
&self.command_line.dynamic_commands,
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
{
|
{
|
||||||
if !top.eq_ignore_ascii_case(cmd) {
|
if !top.eq_ignore_ascii_case(cmd) {
|
||||||
return self.dispatch_command_inner(top, false);
|
return self.dispatch_command_inner(&top, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||||
// rather than the partial text actually in the buffer.
|
// rather than the partial text actually in the buffer.
|
||||||
let i_tab = self.active_tab;
|
let i_tab = self.active_tab;
|
||||||
if self.tabs[i_tab].active_cmd.is_none() {
|
if self.tabs[i_tab].active_cmd.is_none() {
|
||||||
if let Some(picked) = self.command_line.selected_suggestion() {
|
if let Some(cmd) = self.command_line.selected_suggestion() {
|
||||||
let cmd = picked.to_string();
|
|
||||||
self.command_line.input.clear();
|
self.command_line.input.clear();
|
||||||
self.command_line.autocomplete_cursor = None;
|
self.command_line.autocomplete_cursor = None;
|
||||||
return self.dispatch_command(&cmd);
|
return self.dispatch_command(&cmd);
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,11 @@ impl OpenCADStudio {
|
||||||
let modules =
|
let modules =
|
||||||
crate::plugin::ribbon_modules_enabled(&self.disabled_plugins);
|
crate::plugin::ribbon_modules_enabled(&self.disabled_plugins);
|
||||||
self.ribbon.set_modules(modules);
|
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
|
/// Snapshot of disabled plugin ids — lets the registry skip them while it
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ pub mod host;
|
||||||
pub mod marketplace;
|
pub mod marketplace;
|
||||||
pub mod registry;
|
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;
|
pub(crate) use registry::try_dispatch;
|
||||||
|
|
||||||
/// Run a plugin entry point under a panic guard so a buggy external plugin
|
/// Run a plugin entry point under a panic guard so a buggy external plugin
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,24 @@ pub fn ribbon_modules_enabled(
|
||||||
core
|
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<String>) -> Vec<String> {
|
||||||
|
#[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).
|
/// Dispatch `cmd` to a loaded external plugin (skipping disabled ones).
|
||||||
/// Returns true if one handled it.
|
/// Returns true if one handled it.
|
||||||
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ pub struct CommandLine {
|
||||||
/// `None` when the user hasn't yet started navigating with the
|
/// `None` when the user hasn't yet started navigating with the
|
||||||
/// arrow keys. Reset on every keystroke.
|
/// arrow keys. Reset on every keystroke.
|
||||||
pub autocomplete_cursor: Option<usize>,
|
pub autocomplete_cursor: Option<usize>,
|
||||||
|
/// 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<String>,
|
||||||
/// The active command step's prompt, mirrored here so a step change
|
/// The active command step's prompt, mirrored here so a step change
|
||||||
/// can be detected and the pinned (non-fading) history line updated.
|
/// can be detected and the pinned (non-fading) history line updated.
|
||||||
step_prompt: Option<String>,
|
step_prompt: Option<String>,
|
||||||
|
|
@ -277,16 +282,16 @@ impl CommandLine {
|
||||||
|
|
||||||
/// The command name the user has currently highlighted in the
|
/// The command name the user has currently highlighted in the
|
||||||
/// autocomplete popup, if any.
|
/// autocomplete popup, if any.
|
||||||
pub fn selected_suggestion(&self) -> Option<&'static str> {
|
pub fn selected_suggestion(&self) -> Option<String> {
|
||||||
let matches = self.autocomplete_matches();
|
let matches = self.autocomplete_matches();
|
||||||
self.autocomplete_cursor
|
self.autocomplete_cursor
|
||||||
.and_then(|i| matches.get(i).copied())
|
.and_then(|i| matches.get(i).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Autocomplete suggestions for the current input — see
|
/// Autocomplete suggestions for the current input — see
|
||||||
/// [`ranked_matches`].
|
/// [`ranked_matches`]. Includes loaded plugins' commands (#272).
|
||||||
pub fn autocomplete_matches(&self) -> Vec<&'static str> {
|
pub fn autocomplete_matches(&self) -> Vec<String> {
|
||||||
ranked_matches(self.input.trim())
|
ranked_matches(self.input.trim(), &self.dynamic_commands)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view<'a>(
|
pub fn view<'a>(
|
||||||
|
|
@ -372,8 +377,8 @@ impl CommandLine {
|
||||||
let mut col = column![].spacing(0).width(Length::Fill);
|
let mut col = column![].spacing(0).width(Length::Fill);
|
||||||
for (idx, cmd) in matches.iter().enumerate() {
|
for (idx, cmd) in matches.iter().enumerate() {
|
||||||
let is_selected = cursor == idx;
|
let is_selected = cursor == idx;
|
||||||
let row = button(text(*cmd).size(11).color(CMD_COLOR))
|
let row = button(text(cmd.clone()).size(11).color(CMD_COLOR))
|
||||||
.on_press(Message::CommandSuggestionPick(cmd.to_string()))
|
.on_press(Message::CommandSuggestionPick(cmd.clone()))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.padding([2, 8])
|
.padding([2, 8])
|
||||||
.style(move |_: &Theme, status| {
|
.style(move |_: &Theme, status| {
|
||||||
|
|
@ -564,23 +569,32 @@ impl CommandLine {
|
||||||
/// Shared by the suggestion popup and the Enter-key closest-match fallback so
|
/// Shared by the suggestion popup and the Enter-key closest-match fallback so
|
||||||
/// both agree on the top suggestion.
|
/// both agree on the top suggestion.
|
||||||
///
|
///
|
||||||
/// Names come from `crate::command::all_registered_command_names()` which
|
/// Names come from `crate::command::all_registered_command_names()` — the
|
||||||
/// collects every `inventory::submit!` block placed next to a `CadCommand`
|
/// compile-time `inventory` registry — merged with `dynamic`, the command names
|
||||||
/// impl — no central list to maintain.
|
/// contributed by loaded plugins (runtime, so they can't be `&'static`; see
|
||||||
pub fn ranked_matches(needle: &str) -> Vec<&'static str> {
|
/// #272). Returns owned strings to carry both sources.
|
||||||
|
pub fn ranked_matches(needle: &str, dynamic: &[String]) -> Vec<String> {
|
||||||
let needle = needle.trim().to_uppercase();
|
let needle = needle.trim().to_uppercase();
|
||||||
if needle.is_empty() {
|
if needle.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut matches: Vec<&'static str> = crate::command::all_registered_command_names()
|
let mut matches: Vec<String> = crate::command::all_registered_command_names()
|
||||||
.into_iter()
|
.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))
|
.filter(|cmd| cmd.contains(&needle))
|
||||||
.collect();
|
.collect();
|
||||||
matches.sort();
|
matches.sort();
|
||||||
matches.dedup();
|
matches.dedup();
|
||||||
// Prefix matches rank above mid-string ones, then alphabetical so the
|
// Prefix matches rank above mid-string ones, then alphabetical so the
|
||||||
// order is stable as the user keeps typing.
|
// 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.truncate(AUTOCOMPLETE_LIMIT);
|
||||||
matches
|
matches
|
||||||
}
|
}
|
||||||
|
|
@ -671,3 +685,27 @@ const INFO_COLOR: Color = Color {
|
||||||
a: 1.0,
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue