use std::sync::atomic::Ordering;
use crossterm::event::{KeyCode, KeyEvent};
use log::debug;
use crate::app::{App, Screen};
pub(super) fn handle_key(app: &mut App, key: KeyEvent) {
if app.search.query.is_some() {
handle_search_keys(app, key);
return;
}
match key.code {
KeyCode::Tab => {
app.cycle_top_page_next();
app.search.query = None;
}
KeyCode::BackTab => {
app.cycle_top_page_prev();
app.search.query = None;
}
KeyCode::Char('j') | KeyCode::Down | KeyCode::Right => {
app.select_next_key();
}
KeyCode::Char('k') | KeyCode::Up | KeyCode::Left => {
app.select_prev_key();
}
KeyCode::PageDown => {
crate::app::page_down(&mut app.keys.list_state, app.keys.list.len(), 10);
}
KeyCode::PageUp => {
crate::app::page_up(&mut app.keys.list_state, app.keys.list.len(), 10);
}
KeyCode::Home | KeyCode::Char('g') if !app.keys.list.is_empty() => {
app.keys.list_state.select(Some(0));
}
KeyCode::End | KeyCode::Char('G') if !app.keys.list.is_empty() => {
app.keys.list_state.select(Some(app.keys.list.len() - 1));
}
KeyCode::Enter | KeyCode::Char('c') => {
copy_selected_pubkey(app);
}
KeyCode::Char('p') => {
open_push_picker(app);
}
KeyCode::Char('V') => {
super::host_list::actions::initiate_bulk_vault_sign(app);
}
KeyCode::Char('/') => {
app.search.query = Some(String::new());
if !app.keys.list.is_empty() {
app.keys.list_state.select(Some(0));
}
log::debug!("[purple] keys: opened search");
}
KeyCode::Char(':') => {
log::debug!("jump: opened from keys overview");
app.open_jump(crate::app::JumpMode::Keys);
}
KeyCode::Char('n') => {
super::whats_new::dismiss_whats_new_toast(app);
app.set_screen(Screen::WhatsNew(crate::app::WhatsNewState::default()));
}
KeyCode::Char('?') => {
app.set_screen(Screen::Help {
return_screen: Box::new(Screen::HostList),
});
}
KeyCode::Char('q') => {
app.running = false;
}
KeyCode::Esc if push_in_flight(app) => {
cancel_push_if_running(app);
}
KeyCode::Esc
if !app.ui.esc_quit_hint_shown
&& !app.status_center.toast.as_ref().is_some_and(|t| t.sticky) =>
{
log::debug!("[purple] esc on idle keys overview, showing quit hint toast");
app.notify(crate::messages::ESC_QUIT_HINT);
app.ui.esc_quit_hint_shown = true;
}
_ => {}
}
}
pub(super) fn push_in_flight(app: &App) -> bool {
app.keys.push.expected_count > 0 && app.keys.push.cancel.is_some()
}
fn cancel_push_if_running(app: &mut App) {
let done = app.keys.push.results.len();
let total = app.keys.push.expected_count;
if let Some(ref cancel) = app.keys.push.cancel {
cancel.store(true, Ordering::Relaxed);
}
log::debug!(
"[purple] key_push: cancel requested, done={}/{}",
done,
total
);
app.keys.push.results.clear();
app.keys.push.expected_count = 0;
app.keys.push.cancel = None;
app.keys.push.selected.clear();
app.keys.push.run_id = app.keys.push.run_id.wrapping_add(1);
app.status_center.clear_sticky_status();
app.notify(crate::messages::key_push_cancelled(done, total));
}
fn handle_search_keys(app: &mut App, key: KeyEvent) {
let filtered =
crate::ssh_keys::filtered_key_indices(&app.keys.list, app.search.query.as_deref());
let count = filtered.len();
match key.code {
KeyCode::Esc => {
app.search.query = None;
if !app.keys.list.is_empty() {
app.keys.list_state.select(Some(0));
} else {
app.keys.list_state.select(None);
}
}
KeyCode::Enter => {
copy_selected_pubkey(app);
app.search.query = None;
}
KeyCode::Tab => {
app.search.query = None;
app.cycle_top_page_next();
}
KeyCode::BackTab => {
app.search.query = None;
app.cycle_top_page_prev();
}
KeyCode::Down | KeyCode::Right if count > 0 => {
let cur = app.keys.list_state.selected().unwrap_or(0);
app.keys.list_state.select(Some((cur + 1).min(count - 1)));
}
KeyCode::Up | KeyCode::Left if count > 0 => {
let cur = app.keys.list_state.selected().unwrap_or(0);
app.keys.list_state.select(Some(cur.saturating_sub(1)));
}
KeyCode::PageDown => {
crate::app::page_down(&mut app.keys.list_state, count, 10);
}
KeyCode::PageUp => {
crate::app::page_up(&mut app.keys.list_state, count, 10);
}
KeyCode::Backspace => {
if let Some(q) = app.search.query.as_mut() {
q.pop();
}
let new_count =
crate::ssh_keys::filtered_key_indices(&app.keys.list, app.search.query.as_deref())
.len();
if new_count == 0 {
app.keys.list_state.select(None);
} else {
app.keys.list_state.select(Some(0));
}
}
KeyCode::Char(c) => {
if let Some(q) = app.search.query.as_mut() {
q.push(c);
}
let new_count =
crate::ssh_keys::filtered_key_indices(&app.keys.list, app.search.query.as_deref())
.len();
if new_count == 0 {
app.keys.list_state.select(None);
} else {
app.keys.list_state.select(Some(0));
}
}
_ => {}
}
}
fn copy_selected_pubkey(app: &mut App) {
let Some(sel) = app.keys.list_state.selected() else {
return;
};
let Some(idx) =
crate::ssh_keys::resolve_selection(&app.keys.list, app.search.query.as_deref(), sel)
else {
return;
};
let Some(key_info) = app.keys.list.get(idx) else {
return;
};
let pub_path = format!("{}.pub", key_info.display_path);
let expanded = expand_tilde(&pub_path);
let body = match std::fs::read_to_string(&expanded) {
Ok(s) => s,
Err(e) => {
debug!(
"[purple] keys: read pubkey failed path={} err={}",
expanded, e
);
app.notify_error(crate::messages::keys_copy_read_failed(&key_info.name));
return;
}
};
match crate::clipboard::copy_to_clipboard(body.trim_end()) {
Ok(()) => {
debug!("[purple] keys: copied pubkey for {}", key_info.name);
app.notify(crate::messages::keys_copy_success(&key_info.name));
}
Err(e) => {
debug!("[purple] keys: clipboard copy failed: {}", e);
app.notify_error(e);
}
}
}
fn open_push_picker(app: &mut App) {
let Some(sel) = app.keys.list_state.selected() else {
return;
};
let Some(key_index) =
crate::ssh_keys::resolve_selection(&app.keys.list, app.search.query.as_deref(), sel)
else {
return;
};
if app.keys.list.get(key_index).is_none() {
return;
}
if app.hosts_state.list.is_empty() {
app.notify_warning(crate::messages::PICKER_NO_HOSTS);
return;
}
app.keys.push.reset_picker();
app.set_screen(Screen::KeyPushPicker { key_index });
log::debug!("[purple] keys: opened push picker for index={}", key_index);
}
fn expand_tilde(p: &str) -> String {
if let Some(rest) = p.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest).display().to_string();
}
}
p.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::ssh_config::model::SshConfigFile;
use crate::ssh_keys::SshKeyInfo;
use crossterm::event::KeyModifiers;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
#[test]
fn expand_tilde_replaces_prefix() {
let result = expand_tilde("~/.ssh/id_ed25519.pub");
assert!(result.contains(".ssh/id_ed25519.pub"));
assert!(!result.starts_with('~'));
}
#[test]
fn expand_tilde_passthrough_for_absolute() {
assert_eq!(expand_tilde("/tmp/id_ed25519.pub"), "/tmp/id_ed25519.pub");
}
#[test]
fn expand_tilde_passthrough_for_relative() {
assert_eq!(expand_tilde("keys/id_ed25519.pub"), "keys/id_ed25519.pub");
}
fn key(name: &str) -> SshKeyInfo {
SshKeyInfo {
name: name.to_string(),
display_path: format!("~/.ssh/{}", name),
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,
}
}
fn make_app() -> 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(""),
path: scratch.join("test_config"),
crlf: false,
bom: false,
};
App::new(config)
}
fn k(c: KeyCode) -> KeyEvent {
KeyEvent::new(c, KeyModifiers::NONE)
}
fn seed_one_host(app: &mut App) {
app.hosts_state
.list
.push(crate::ssh_config::model::HostEntry {
alias: "h1".into(),
..Default::default()
});
}
#[test]
fn open_push_picker_under_search_translates_filtered_index() {
let mut app = make_app();
seed_one_host(&mut app);
app.keys.list = vec![key("id_ed25519"), key("yubikey_work"), key("customer-x")];
app.search.query = Some("yubi".to_string());
app.keys.list_state.select(Some(0));
open_push_picker(&mut app);
match app.screen {
Screen::KeyPushPicker { key_index } => {
assert_eq!(
key_index, 1,
"filtered idx 0 must map to app.keys.list idx 1"
);
}
ref other => panic!("expected KeyPushPicker, got {:?}", other),
}
}
#[test]
fn open_push_picker_resets_picker_state() {
let mut app = make_app();
seed_one_host(&mut app);
app.keys.list = vec![key("id_ed25519")];
app.keys.list_state.select(Some(0));
app.keys.push.selected.insert("old-host".to_string());
open_push_picker(&mut app);
assert!(
app.keys.push.selected.is_empty(),
"selection must be reset on new picker open"
);
}
#[test]
fn push_in_flight_true_only_when_cancel_and_expected_set() {
let mut app = make_app();
assert!(!push_in_flight(&app));
app.keys.push.expected_count = 3;
assert!(
!push_in_flight(&app),
"expected_count alone is not in-flight"
);
app.keys.push.cancel = Some(Arc::new(AtomicBool::new(false)));
assert!(push_in_flight(&app), "both fields set: in flight");
app.keys.push.cancel = None;
assert!(!push_in_flight(&app));
}
#[test]
fn esc_cancels_in_flight_push_clears_state() {
let mut app = make_app();
let flag = Arc::new(AtomicBool::new(false));
app.keys.push.cancel = Some(flag.clone());
app.keys.push.expected_count = 5;
app.keys.push.results.push(crate::key_push::KeyPushResult {
alias: "h1".into(),
outcome: crate::key_push::KeyPushOutcome::Appended,
});
app.keys.push.selected.insert("h1".to_string());
handle_key(&mut app, k(KeyCode::Esc));
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(app.keys.push.expected_count, 0);
assert!(app.keys.push.results.is_empty());
assert!(app.keys.push.cancel.is_none());
assert!(app.keys.push.selected.is_empty());
assert!(app.status_center.toast.is_some());
}
#[test]
fn right_arrow_advances_key_selection() {
let mut app = make_app();
app.keys.list = vec![key("a"), key("b"), key("c")];
app.keys.list_state.select(Some(0));
handle_key(&mut app, k(KeyCode::Right));
assert_eq!(app.keys.list_state.selected(), Some(1));
}
#[test]
fn left_arrow_retreats_key_selection() {
let mut app = make_app();
app.keys.list = vec![key("a"), key("b"), key("c")];
app.keys.list_state.select(Some(2));
handle_key(&mut app, k(KeyCode::Left));
assert_eq!(app.keys.list_state.selected(), Some(1));
}
#[test]
fn right_arrow_at_end_wraps_to_first() {
let mut app = make_app();
app.keys.list = vec![key("a"), key("b")];
app.keys.list_state.select(Some(1));
handle_key(&mut app, k(KeyCode::Right));
assert_eq!(app.keys.list_state.selected(), Some(0));
}
#[test]
fn left_arrow_at_start_wraps_to_last() {
let mut app = make_app();
app.keys.list = vec![key("a"), key("b")];
app.keys.list_state.select(Some(0));
handle_key(&mut app, k(KeyCode::Left));
assert_eq!(app.keys.list_state.selected(), Some(1));
}
#[test]
fn slash_opens_search_and_resets_selection() {
let mut app = make_app();
app.keys.list = vec![key("a"), key("b"), key("c")];
app.keys.list_state.select(Some(2));
handle_key(&mut app, k(KeyCode::Char('/')));
assert_eq!(app.search.query.as_deref(), Some(""));
assert_eq!(
app.keys.list_state.selected(),
Some(0),
"search must land cursor on the first match"
);
}
#[test]
fn search_typing_appends_to_query() {
let mut app = make_app();
app.keys.list = vec![key("alpha"), key("bravo")];
handle_key(&mut app, k(KeyCode::Char('/')));
handle_key(&mut app, k(KeyCode::Char('a')));
handle_key(&mut app, k(KeyCode::Char('l')));
assert_eq!(app.search.query.as_deref(), Some("al"));
}
#[test]
fn search_esc_clears_query() {
let mut app = make_app();
app.keys.list = vec![key("alpha")];
handle_key(&mut app, k(KeyCode::Char('/')));
handle_key(&mut app, k(KeyCode::Char('a')));
handle_key(&mut app, k(KeyCode::Esc));
assert!(app.search.query.is_none(), "Esc must close search");
}
#[test]
fn search_backspace_on_empty_query_is_noop_and_keeps_search_open() {
let mut app = make_app();
app.keys.list = vec![key("alpha")];
handle_key(&mut app, k(KeyCode::Char('/')));
handle_key(&mut app, k(KeyCode::Backspace));
assert_eq!(app.search.query.as_deref(), Some(""));
assert_eq!(app.keys.list_state.selected(), Some(0));
}
#[test]
fn tab_cycles_to_next_top_page_and_closes_search() {
let mut app = make_app();
app.top_page = crate::app::TopPage::Keys;
app.search.query = None;
handle_key(&mut app, k(KeyCode::Tab));
assert!(!matches!(app.top_page, crate::app::TopPage::Keys));
}
#[test]
fn tab_in_search_mode_exits_search_before_cycling() {
let mut app = make_app();
app.top_page = crate::app::TopPage::Keys;
app.keys.list = vec![key("alpha")];
handle_key(&mut app, k(KeyCode::Char('/')));
handle_key(&mut app, k(KeyCode::Tab));
assert!(app.search.query.is_none());
assert!(!matches!(app.top_page, crate::app::TopPage::Keys));
}
#[test]
fn q_quits_the_app() {
let mut app = make_app();
assert!(app.running);
handle_key(&mut app, k(KeyCode::Char('q')));
assert!(!app.running);
}
#[test]
fn copy_pubkey_on_empty_list_is_noop() {
let mut app = make_app();
app.keys.list.clear();
app.keys.list_state.select(None);
handle_key(&mut app, k(KeyCode::Enter));
}
#[test]
fn n_opens_whats_new_overlay() {
let mut app = make_app();
app.keys.list = vec![key("a")];
handle_key(&mut app, k(KeyCode::Char('n')));
assert!(matches!(app.screen, Screen::WhatsNew(_)));
}
}