//! Command palette + menu dispatch for the holger UI — the `facett::menu` command
//! set (`goto:` / `tool:` / `repo:` / `action:` / `look:`) and the single
//! interpreter that routes a picked id through the SAME hooks a click runs. Split
//! out of [`super`] (`app.rs`) to keep that module focused on rendering; the impl
//! block here is a child module of `app`, so it still reaches `HolgerUiApp`'s
//! private state directly.
use facett::menu::Command;
use super::{look_names, HolgerUiApp, Tab, ToolsFn};
/// Map a `goto:<id>` palette-command suffix to its [`Tab`] (the inverse of the
/// label mapping in [`HolgerUiApp::state_json`]). `None` for an unknown id.
fn tab_from_id(id: &str) -> Option<Tab> {
Some(match id {
"status" => Tab::Status,
"repos" => Tab::Repos,
"browse" => Tab::Browse,
"archive" => Tab::Archive,
"artifact" => Tab::Artifact,
#[cfg(all(feature = "mini", not(feature = "mikro")))]
"search" => Tab::Search,
"graph" => Tab::Graph,
"helix" => Tab::Helix,
"lineage" => Tab::Lineage,
"tools" => Tab::Tools,
"upload" => Tab::Upload,
"signin" => Tab::SignIn,
"settings" => Tab::Settings,
_ => return None,
})
}
/// Map a `tool:<id>` palette-command suffix to its [`ToolsFn`]. `None` for unknown.
fn tool_from_id(id: &str) -> Option<ToolsFn> {
Some(match id {
"holger-ds" => ToolsFn::HolgerDs,
"migrate" => ToolsFn::Migrate,
"security" => ToolsFn::Security,
"deploy" => ToolsFn::Deploy,
"airgap" => ToolsFn::Airgap,
"demo" => ToolsFn::Demo,
_ => return None,
})
}
impl HolgerUiApp {
/// Build the palette command set for the current state: every **tab** (`goto:*`),
/// every operator **tool** (`tool:*`), one command per **repository** (`repo:*`,
/// built at runtime — the reason this needs facett's owned-String command ids),
/// a few **actions** (`action:*`), and the **look** presets (`look:*`). The ids
/// are stable + headless-addressable, so [`run_command`](Self::run_command) fires
/// the same paths a click would. Rebuilt each frame (cheap; a flat Vec).
pub(super) fn build_commands(&self) -> Vec<Command> {
// Palette SECTION headings. In mini the View + Repositories MENUS are gone, so those
// headings would name menus that no longer exist — label the groups for what the
// commands DO instead (the commands themselves are unchanged and still valid).
#[cfg(feature = "mini")]
let (goto_group, repo_group) = ("Go to", "Open repo");
#[cfg(not(feature = "mini"))]
let (goto_group, repo_group) = ("View", "Repositories");
let mut c = Vec::new();
// Derived from `TAB_ORDER` + `Tab::label()` — the SAME single source of truth
// the scene's pane tree and View menu are built from, so a tab added there
// gains its palette command automatically. This list used to be written out by
// hand: a twin of `TAB_ORDER` (L5) that made "add a tab" a three-place edit,
// where forgetting the third gave the new tab a pane and a menu entry but NO
// Ctrl-K command — a hole nothing would have failed on. See
// `every_tab_has_exactly_one_palette_command`.
for t in super::TAB_ORDER {
// Upload is unavailable (and hidden) in the read-only static showcase.
if t == Tab::Upload && self.static_mode {
continue;
}
c.push(Command::new(
format!("goto:{}", t.slot_id()),
format!("Go to {}", t.label()),
goto_group,
));
}
// Operator tools (holger-ds / migrate / scan / deploy / airgap / demo) are a
// `full`-tier surface — federation, host provisioning, benchmark egress — so the
// mini artifact omits them, mirroring the dropped Tools tab in `TAB_ORDER`.
#[cfg(not(feature = "mini"))]
for (id, label) in [
("holger-ds", "holger-ds (delivery bench)"),
("migrate", "Migrate"),
("security", "Security scan (Móðguðr)"),
("deploy", "Deploy (Skíðblaðnir)"),
("airgap", "Airgap populate"),
("demo", "Demo appliance (jera container)"),
] {
// Group "Operate" (not "Tools") so the menu-bar top-level button never
// collides with the "Tools" TAB label (robot queries stay unambiguous).
c.push(Command::new(format!("tool:{id}"), format!("Tool: {label}"), "Operate"));
}
// One command per repository the server reported — the runtime-built set.
// Group "Repositories" (not "Repos") to avoid colliding with the "Repos" tab.
for r in &self.data.repos.repos {
c.push(
Command::new(format!("repo:{}", r.name), format!("Open repo · {}", r.name), repo_group)
.keywords([r.name.as_str(), "repository", "browse"]),
);
}
c.push(Command::new("action:refresh", "Refresh repositories", "Action").shortcut("F5"));
// Móðguðr scan is a `full`-tier tool (opens the Tools tab) — dropped in mini.
#[cfg(not(feature = "mini"))]
c.push(Command::new("action:scan-selected", "Scan selected repo", "Action"));
// The static / Skade-Vinter toggle is the demo showcase — absent in the mini tier.
#[cfg(not(feature = "mini"))]
c.push(Command::new("action:toggle-static", "Toggle static (Skade Vinter) mode", "Action"));
// The shared common-framework identity surface — grouped under "Help" so the
// menu-bar top-level button reads like every JetBrains-style app's Help·About.
c.push(Command::new("about:open", "About holger", "Help"));
// The look presets stay in the Ctrl-K palette as a direct-jump ACCELERATOR —
// Ctrl-K is a searchable index of everything the app can do, not a menu, and
// dropping these ids would break the demo tour, the `look:<name>` headless
// drives and the robot scenarios that assert re-theming. The group is
// "Appearance", not "Look": it used to be named after the root menu-bar Look
// submenu, which is gone — the control itself lives in the ⚙ Settings pane, and
// the group now names the thing rather than a menu that no longer exists (the
// same fix the mini tier's `goto_group` / `repo_group` rename made).
for name in look_names() {
c.push(Command::new(
format!("look:{name}"),
format!("Look: {name}"),
"Appearance",
));
}
c
}
/// Dispatch a palette command id through the SAME hooks a click runs (tab switch,
/// tool select, repo select→Browse, refresh, look). Unknown ids are ignored. This
/// is the one place palette ids are interpreted — [`run_command`](Self::run_command)
/// (headless) and the live `palette.ui` pick both route here.
pub(super) fn dispatch_command(&mut self, id: &str) {
if let Some(tab) = id.strip_prefix("goto:") {
if let Some(t) = tab_from_id(tab) {
self.tab = t;
}
} else if let Some(tool) = id.strip_prefix("tool:") {
if let Some(tf) = tool_from_id(tool) {
self.tab = Tab::Tools;
self.tool = tf;
}
} else if let Some(name) = id.strip_prefix("repo:") {
if let Some(i) = self.data.repos.repos.iter().position(|r| r.name == name) {
self.select_repo(i);
self.tab = Tab::Browse;
}
} else if let Some(look) = id.strip_prefix("look:") {
if let Some(i) = look_names().iter().position(|n| n == look) {
self.set_look_preset(i);
}
} else {
match id {
"action:refresh" => self.data.refresh_repos(),
"action:scan-selected" => {
self.tab = Tab::Tools;
self.tool = ToolsFn::Security;
}
"action:toggle-static" => self.static_mode = !self.static_mode,
"about:open" => self.about_open = true,
_ => {}
}
}
}
/// Headless hook: fire a palette command by id (CLI / robot parity with a live
/// pick), recording it on the palette's `invoked` observable and running its
/// effect — so a test drives the exact path Ctrl-K → Enter would.
pub fn run_command(&mut self, id: &str) {
self.palette.invoke(id);
self.dispatch_command(id);
}
/// Run a per-repo right-click **navigation** action (`row:browse` / `row:scan` /
/// `row:archive`): select row `i`, then open the matching tab (Scan → Tools/
/// Security). `row:copy` is handled at the call site (it needs the egui
/// clipboard). Extracted so a headless test drives the same path a pick runs.
pub fn apply_repo_row_nav(&mut self, i: usize, action: &str) {
self.select_repo(i);
match action {
"row:browse" => self.tab = Tab::Browse,
"row:scan" => {
self.tab = Tab::Tools;
self.tool = ToolsFn::Security;
}
"row:archive" => self.tab = Tab::Archive,
_ => {}
}
}
}