use crossterm::event::{KeyCode, KeyEvent};
use crate::app::{App, Screen};
use crate::ssh_config::model::HostEntry;
pub(crate) fn pickable_hosts(app: &App) -> impl Iterator<Item = &HostEntry> {
app.hosts_state.list.iter()
}
pub(crate) fn is_vault_host(host: &HostEntry) -> bool {
crate::vault_ssh::has_purple_vault_context(host)
}
pub(super) fn handle_keys(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);
}
_ => {}
}
}
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()
);
}
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
);
}
}
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;
}
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);
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_keys(&mut app, k(KeyCode::Char(' ')));
assert!(app.keys.push.selected.contains("h1"));
handle_keys(&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",
);
app.keys.push.list_state.select(Some(0));
handle_keys(&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() {
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_keys(&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_keys(&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_keys(&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_keys(&mut app, k(KeyCode::Char('a')));
assert_eq!(app.keys.push.selected.len(), 2);
handle_keys(&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_keys(&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_keys(&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() {
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_keys(&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_keys(&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_keys(&mut app, k(KeyCode::Down));
assert_eq!(app.keys.push.list_state.selected(), Some(1));
handle_keys(&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_keys(&mut app, k(KeyCode::Up));
assert_eq!(app.keys.push.list_state.selected(), Some(0));
handle_keys(&mut app, k(KeyCode::Up));
assert_eq!(app.keys.push.list_state.selected(), Some(0));
}
}