Gunnar

nordisk/holger-egui

src/app/command.rs

//! 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,
            _ => {}
        }
    }
}