purple-ssh 3.15.13

Open-source terminal SSH manager that keeps ~/.ssh/config in sync with your cloud infra. Spin up a VM on AWS, GCP, Azure, Hetzner or 12 other cloud providers and it appears in your host list. Destroy it and the entry dims. Search hundreds of hosts, transfer files, manage Docker and Podman over SSH, sign Vault SSH certs. Rust TUI, MIT licensed.
Documentation
//! Key handler for `Screen::KeyPushPicker`.
//!
//! Multi-select host picker reached from the Keys tab via `p`. Selection
//! is held in `app.keys.push.selected` (HashSet of aliases). Vault-ssh
//! hosts cannot be selected; Space and `a` skip them.

use crossterm::event::{KeyCode, KeyEvent};

use crate::app::{App, Screen};
use crate::ssh_config::model::HostEntry;

/// Iterator over hosts that may appear in the picker. Vault-managed hosts
/// are included so the user can see they exist; the selection layer
/// (`toggle_at_cursor` / `toggle_select_all_eligible`) refuses to select
/// them. Picker policy lives in the handler layer, not the UI.
pub(crate) fn pickable_hosts(app: &App) -> impl Iterator<Item = &HostEntry> {
    app.hosts_state.list.iter()
}

/// True when a host is purple-Vault-managed: either a `# purple:vault-ssh`
/// role comment is set, or `CertificateFile` points into `~/.purple/certs/`.
/// Both flavours use signed certs rather than `authorized_keys` appends and
/// must not be picked.
pub(crate) fn is_vault_host(host: &HostEntry) -> bool {
    crate::vault_ssh::has_purple_vault_context(host)
}

pub(super) fn handle_key(app: &mut App, key: KeyEvent) {
    let key_index = match app.screen {
        Screen::KeyPushPicker { key_index } => key_index,
        _ => return,
    };
    let host_count = pickable_hosts(app).count();
    match key.code {
        KeyCode::Esc => {
            app.keys.push.selected.clear();
            app.set_screen(Screen::HostList);
        }
        KeyCode::Char('j') | KeyCode::Down => {
            if host_count == 0 {
                return;
            }
            let cur = app.keys.push.list_state.selected().unwrap_or(0);
            app.keys
                .push
                .list_state
                .select(Some((cur + 1).min(host_count - 1)));
        }
        KeyCode::Char('k') | KeyCode::Up => {
            if host_count == 0 {
                return;
            }
            let cur = app.keys.push.list_state.selected().unwrap_or(0);
            app.keys.push.list_state.select(Some(cur.saturating_sub(1)));
        }
        KeyCode::PageDown => {
            crate::app::page_down(&mut app.keys.push.list_state, host_count, 10);
        }
        KeyCode::PageUp => {
            crate::app::page_up(&mut app.keys.push.list_state, host_count, 10);
        }
        KeyCode::Home | KeyCode::Char('g') if host_count > 0 => {
            app.keys.push.list_state.select(Some(0));
        }
        KeyCode::End | KeyCode::Char('G') if host_count > 0 => {
            app.keys.push.list_state.select(Some(host_count - 1));
        }
        KeyCode::Char(' ') => {
            toggle_at_cursor(app);
        }
        KeyCode::Char('a') | KeyCode::Char('A') => {
            toggle_select_all_eligible(app);
        }
        KeyCode::Enter => {
            commit_to_confirm(app, key_index);
        }
        _ => {}
    }
}

/// Flip the selection state of the alias at the cursor. Vault-ssh hosts
/// are skipped so the user cannot accidentally target a cert-managed
/// host with a static-key append.
fn toggle_at_cursor(app: &mut App) {
    let Some(idx) = app.keys.push.list_state.selected() else {
        return;
    };
    let host = match pickable_hosts(app).nth(idx) {
        Some(h) => h,
        None => return,
    };
    if is_vault_host(host) {
        app.notify(crate::messages::KEY_PUSH_VAULT_SKIP);
        return;
    }
    let alias = host.alias.clone();
    if app.keys.push.selected.contains(&alias) {
        app.keys.push.selected.remove(&alias);
    } else {
        app.keys.push.selected.insert(alias.clone());
    }
    log::debug!(
        "[purple] key_push picker: toggled {} (now {} selected)",
        alias,
        app.keys.push.selected.len()
    );
}

/// `a` toggle: if every eligible host is already selected, clear them;
/// otherwise select all eligible. Matches the host-list bulk-select rhythm.
fn toggle_select_all_eligible(app: &mut App) {
    let eligible: Vec<String> = pickable_hosts(app)
        .filter(|h| !is_vault_host(h))
        .map(|h| h.alias.clone())
        .collect();
    if eligible.is_empty() {
        return;
    }
    let all_already_selected = eligible.iter().all(|a| app.keys.push.selected.contains(a));
    if all_already_selected {
        for alias in &eligible {
            app.keys.push.selected.remove(alias);
        }
        log::debug!(
            "[purple] key_push picker: cleared all {} eligible selections",
            eligible.len()
        );
    } else {
        let n = eligible.len();
        for alias in eligible {
            app.keys.push.selected.insert(alias);
        }
        log::debug!(
            "[purple] key_push picker: selected all {} eligible hosts",
            n
        );
    }
}

/// Enter: freeze the selection into `app.keys.push.committed` and
/// transition. Empty selection notifies but stays on the picker so the
/// user gets feedback instead of a silent no-op.
fn commit_to_confirm(app: &mut App, key_index: usize) {
    if app.keys.push.selected.is_empty() {
        app.notify(crate::messages::KEY_PUSH_NONE_SELECTED);
        return;
    }
    // Preserve picker order (the user just saw the list in this order),
    // not HashSet iteration order, so the confirm dialog reads stably.
    let aliases: Vec<String> = pickable_hosts(app)
        .map(|h| h.alias.clone())
        .filter(|a| app.keys.push.selected.contains(a))
        .collect();
    app.keys.push.committed = aliases;
    app.set_screen(Screen::ConfirmKeyPush { key_index });
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::App;
    use crate::ssh_config::model::SshConfigFile;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    fn make_app(content: &str) -> App {
        let scratch = tempfile::tempdir().expect("tempdir").keep();
        crate::preferences::set_path_override(scratch.join("preferences"));
        crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
        let config = SshConfigFile {
            elements: SshConfigFile::parse_content(content),
            path: scratch.join("test_config"),
            crlf: false,
            bom: false,
        };
        let mut app = App::new(config);
        // Seed a minimal key so commit_to_confirm can identify it.
        app.keys.list = vec![crate::ssh_keys::SshKeyInfo {
            name: "id_ed25519".into(),
            display_path: "~/.ssh/id_ed25519".into(),
            key_type: "ED25519".into(),
            bits: "256".into(),
            fingerprint: String::new(),
            comment: String::new(),
            linked_hosts: vec![],
            bishop_art: String::new(),
            strength_score: 90,
            encrypted: true,
            agent_loaded: false,
            is_certificate: false,
            mtime_ts: None,
        }];
        app.screen = Screen::KeyPushPicker { key_index: 0 };
        app.keys.push.list_state.select(Some(0));
        app
    }

    fn k(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    #[test]
    fn space_toggles_selection_on_eligible_host() {
        let mut app = make_app("Host h1\n  HostName 1.1.1.1\nHost h2\n  HostName 2.2.2.2\n");
        handle_key(&mut app, k(KeyCode::Char(' ')));
        assert!(app.keys.push.selected.contains("h1"));
        handle_key(&mut app, k(KeyCode::Char(' ')));
        assert!(
            !app.keys.push.selected.contains("h1"),
            "second Space deselects"
        );
    }

    #[test]
    fn space_on_vault_host_does_not_select_and_notifies() {
        let mut app = make_app(
            "Host h1\n  HostName 1.1.1.1\n  # purple:vault-ssh ops/prod\nHost h2\n  HostName 2.2.2.2\n",
        );
        // Cursor on h1 (vault).
        app.keys.push.list_state.select(Some(0));
        handle_key(&mut app, k(KeyCode::Char(' ')));
        assert!(!app.keys.push.selected.contains("h1"));
        assert!(app.status_center.toast.is_some(), "vault skip should toast");
    }

    #[test]
    fn space_on_purple_cert_file_host_is_treated_as_vault() {
        // Regression guard: a host without `# purple:vault-ssh` but with
        // `CertificateFile ~/.purple/certs/<alias>-cert.pub` is still
        // purple-Vault-managed and must not receive a static-key append.
        let mut app = make_app(
            "Host signed-prod\n  HostName 10.0.0.1\n  CertificateFile ~/.purple/certs/signed-prod-cert.pub\nHost plain\n  HostName 2.2.2.2\n",
        );
        app.keys.push.list_state.select(Some(0));
        handle_key(&mut app, k(KeyCode::Char(' ')));
        assert!(
            !app.keys.push.selected.contains("signed-prod"),
            "cert-file vault host must not be selectable"
        );
        assert!(app.status_center.toast.is_some());
    }

    #[test]
    fn a_skips_both_role_and_cert_file_vault_hosts() {
        let mut app = make_app(
            "Host plain\n  HostName 1.1.1.1\nHost role-vault\n  HostName 2.2.2.2\n  # purple:vault-ssh ops/prod\nHost cert-vault\n  HostName 3.3.3.3\n  CertificateFile ~/.purple/certs/cert-vault-cert.pub\nHost plain2\n  HostName 4.4.4.4\n",
        );
        handle_key(&mut app, k(KeyCode::Char('a')));
        assert!(app.keys.push.selected.contains("plain"));
        assert!(app.keys.push.selected.contains("plain2"));
        assert!(!app.keys.push.selected.contains("role-vault"));
        assert!(!app.keys.push.selected.contains("cert-vault"));
    }

    #[test]
    fn a_selects_all_eligible_skipping_vault() {
        let mut app = make_app(
            "Host h1\n  HostName 1.1.1.1\nHost h2\n  HostName 2.2.2.2\n  # purple:vault-ssh ops/prod\nHost h3\n  HostName 3.3.3.3\n",
        );
        handle_key(&mut app, k(KeyCode::Char('a')));
        assert!(app.keys.push.selected.contains("h1"));
        assert!(
            !app.keys.push.selected.contains("h2"),
            "vault host excluded"
        );
        assert!(app.keys.push.selected.contains("h3"));
    }

    #[test]
    fn a_again_clears_when_all_eligible_already_selected() {
        let mut app = make_app("Host h1\n  HostName 1.1.1.1\nHost h2\n  HostName 2.2.2.2\n");
        handle_key(&mut app, k(KeyCode::Char('a')));
        assert_eq!(app.keys.push.selected.len(), 2);
        handle_key(&mut app, k(KeyCode::Char('a')));
        assert!(app.keys.push.selected.is_empty());
    }

    #[test]
    fn enter_with_empty_selection_notifies_and_stays() {
        let mut app = make_app("Host h1\n  HostName 1.1.1.1\n");
        handle_key(&mut app, k(KeyCode::Enter));
        assert!(matches!(app.screen, Screen::KeyPushPicker { .. }));
        assert!(app.status_center.toast.is_some());
    }

    #[test]
    fn enter_with_selection_transitions_to_confirm() {
        let mut app = make_app("Host h1\n  HostName 1.1.1.1\nHost h2\n  HostName 2.2.2.2\n");
        app.keys.push.selected.insert("h1".to_string());
        app.keys.push.selected.insert("h2".to_string());
        handle_key(&mut app, k(KeyCode::Enter));
        match app.screen {
            Screen::ConfirmKeyPush { .. } => {
                assert_eq!(app.keys.push.committed.len(), 2);
            }
            ref other => panic!("expected ConfirmKeyPush, got {:?}", other),
        }
    }

    #[test]
    fn enter_commit_preserves_picker_order() {
        // HashSet iteration order is non-deterministic, but commit_to_confirm
        // walks the picker's host list and filters by selection so the
        // confirm dialog reads in the visual order the user just saw.
        let mut app = make_app(
            "Host alpha\n  HostName 1.1.1.1\nHost beta\n  HostName 2.2.2.2\nHost gamma\n  HostName 3.3.3.3\n",
        );
        app.keys.push.selected.insert("gamma".to_string());
        app.keys.push.selected.insert("alpha".to_string());
        handle_key(&mut app, k(KeyCode::Enter));
        assert_eq!(app.keys.push.committed, vec!["alpha", "gamma"]);
    }

    #[test]
    fn esc_clears_selection_and_returns_to_host_list() {
        let mut app = make_app("Host h1\n  HostName 1.1.1.1\n");
        app.keys.push.selected.insert("h1".to_string());
        handle_key(&mut app, k(KeyCode::Esc));
        assert!(app.keys.push.selected.is_empty());
        assert!(matches!(app.screen, Screen::HostList));
    }

    #[test]
    fn down_moves_cursor_within_bounds() {
        let mut app = make_app("Host h1\n  HostName 1.1.1.1\nHost h2\n  HostName 2.2.2.2\n");
        app.keys.push.list_state.select(Some(0));
        handle_key(&mut app, k(KeyCode::Down));
        assert_eq!(app.keys.push.list_state.selected(), Some(1));
        // Down at end stays at end.
        handle_key(&mut app, k(KeyCode::Down));
        assert_eq!(app.keys.push.list_state.selected(), Some(1));
    }

    #[test]
    fn up_moves_cursor_clamped_to_zero() {
        let mut app = make_app("Host h1\n  HostName 1.1.1.1\nHost h2\n  HostName 2.2.2.2\n");
        app.keys.push.list_state.select(Some(1));
        handle_key(&mut app, k(KeyCode::Up));
        assert_eq!(app.keys.push.list_state.selected(), Some(0));
        handle_key(&mut app, k(KeyCode::Up));
        assert_eq!(app.keys.push.list_state.selected(), Some(0));
    }
}