holger-ui 0.1.4

Operator/admin UI for holger over the HolgerObject core API — egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! 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,
        "graph" => Tab::Graph,
        "helix" => Tab::Helix,
        "lineage" => Tab::Lineage,
        "tools" => Tab::Tools,
        "upload" => Tab::Upload,
        _ => 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> {
        let mut c = Vec::new();
        for (id, label) in [
            ("status", "Status"),
            ("repos", "Repos"),
            ("browse", "Browse"),
            ("archive", "Archive"),
            ("artifact", "Artifact"),
            ("graph", "Graph"),
            ("helix", "Helix"),
            ("lineage", "Lineage"),
            ("tools", "Tools"),
        ] {
            c.push(Command::new(format!("goto:{id}"), format!("Go to {label}"), "View"));
        }
        // Upload is unavailable (and hidden) in the read-only static showcase.
        if !self.static_mode {
            c.push(Command::new("goto:upload", "Go to Upload", "View"));
        }
        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), "Repositories")
                    .keywords([r.name.as_str(), "repository", "browse"]),
            );
        }
        c.push(Command::new("action:refresh", "Refresh repositories", "Action").shortcut("F5"));
        c.push(Command::new("action:scan-selected", "Scan selected repo", "Action"));
        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"));
        for name in look_names() {
            c.push(Command::new(format!("look:{name}"), format!("Look: {name}"), "Look"));
        }
        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,
            _ => {}
        }
    }
}