use std::sync::mpsc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::{App, Screen, ViewMode};
use crate::clipboard;
use crate::event::AppEvent;
use crate::preferences;
use crate::ssh_config::model::ConfigElement;
pub(crate) mod actions;
pub(super) fn stale_provider_hint(host: &crate::ssh_config::model::HostEntry) -> String {
host.provider
.as_ref()
.map(|p| format!("Gone from {}", crate::providers::provider_display_name(p)))
.unwrap_or_default()
}
fn serialize_host_block(elements: &[ConfigElement], alias: &str, crlf: bool) -> Option<String> {
let line_ending = if crlf { "\r\n" } else { "\n" };
for element in elements {
match element {
ConfigElement::HostBlock(block) if block.host_pattern == alias => {
let mut output = block.raw_host_line.clone();
for directive in &block.directives {
output.push_str(line_ending);
output.push_str(&directive.raw_line);
}
return Some(output);
}
ConfigElement::Include(include) => {
for file in &include.resolved_files {
if let Some(result) = serialize_host_block(&file.elements, alias, crlf) {
return Some(result);
}
}
}
_ => {}
}
}
None
}
pub(super) fn handle_key(app: &mut App, key: KeyEvent, events_tx: &mpsc::Sender<AppEvent>) {
match app.top_page {
crate::app::TopPage::Tunnels => super::tunnels_overview::handle_key(app, key),
crate::app::TopPage::Containers => {
super::containers_overview::handle_key(app, key, events_tx);
}
crate::app::TopPage::Keys => super::keys_overview::handle_key(app, key),
crate::app::TopPage::Hosts => {
if app.search.query().is_some() {
handle_search_key(app, key, events_tx);
} else {
handle_main_key(app, key, events_tx);
}
}
}
if matches!(app.top_page, crate::app::TopPage::Containers)
&& matches!(app.screen, Screen::HostList)
{
super::containers_overview::ensure_inspect_for_selected(app, events_tx);
super::containers_overview::ensure_logs_for_selected(app, events_tx);
super::containers_overview::ensure_list_for_selected_host(app, events_tx);
super::containers_overview::ensure_inspect_for_host_header(app, events_tx);
}
}
pub(super) fn handle_main_key(app: &mut App, key: KeyEvent, events_tx: &mpsc::Sender<AppEvent>) {
if app.tags.input().is_some() {
super::host_detail::handle_tag_input(app, key);
return;
}
match key.code {
KeyCode::Char('q') => {
if let Some(cancel) = app.vault.signing_cancel() {
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
app.running = false;
}
KeyCode::Esc => {
if app.hosts_state.group_filter().is_some() {
app.clear_group_filter();
} else if !app.hosts_state.multi_select().is_empty() {
app.hosts_state.clear_multi_select();
} else if !app.ui.esc_quit_hint_shown()
&& !app.status_center.toast().is_some_and(|t| t.sticky)
{
log::debug!("[purple] esc on idle host list, showing quit hint toast");
app.notify(crate::messages::ESC_QUIT_HINT);
app.ui.set_esc_quit_hint_shown(true);
}
}
KeyCode::Char('j') | KeyCode::Down => {
app.select_next_skipping_headers();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::Char('k') | KeyCode::Up => {
app.select_prev_skipping_headers();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::Tab => {
app.cycle_top_page_next();
app.search.set_query(None);
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::BackTab => {
app.cycle_top_page_prev();
app.search.set_query(None);
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::PageDown => {
app.page_down_host();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::PageUp => {
app.page_up_host();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::Enter => {
if app.is_pattern_selected() {
return;
}
if let Some(host) = app.selected_host() {
let alias = host.alias.clone();
let askpass = host.askpass.clone();
let stale_hint = super::host_form::stale_hint_for(host);
if let Some(hint) = stale_hint {
app.notify_warning(crate::messages::stale_host(&hint));
}
if app.demo_mode {
app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
return;
}
app.ui.queue_connect(alias, askpass);
}
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let visible_indices: Vec<usize> = app
.hosts_state
.display_list()
.iter()
.filter_map(|item| match item {
crate::app::HostListItem::Host { index } => Some(*index),
_ => None,
})
.collect();
let all_selected = !visible_indices.is_empty()
&& visible_indices
.iter()
.all(|idx| app.hosts_state.multi_select().contains(idx));
if all_selected {
app.hosts_state.clear_multi_select();
} else {
for idx in visible_indices {
app.hosts_state.multi_select_mut().insert(idx);
}
}
}
KeyCode::Char('a') => {
app.open_host_add_form();
}
KeyCode::Char('A') => {
app.open_host_pattern_add_form();
}
KeyCode::Char('e') => {
if let Some(pattern) = app.selected_pattern().cloned() {
if pattern.source_file.is_some() {
app.notify_error(crate::messages::included_file_edit(&pattern.pattern));
return;
}
app.open_host_pattern_edit_form(&pattern);
} else if let Some(host) = app.selected_host().cloned() {
let hint = super::host_form::stale_hint_for(&host);
app.open_host_edit_form(host, hint);
}
}
KeyCode::Char('d') => {
if let Some(pattern) = app.selected_pattern() {
if pattern.source_file.is_some() {
app.notify_error(crate::messages::included_file_delete(&pattern.pattern));
return;
}
let alias = pattern.pattern.clone();
app.set_screen(Screen::ConfirmDelete { alias });
} else if let Some(host) = app.selected_host() {
if let Some(ref source) = host.source_file {
let alias = host.alias.clone();
let path = source.display();
app.notify_warning(crate::messages::included_host_lives_in(&alias, &path));
return;
}
let stale_hint = super::host_form::stale_hint_for(host);
let alias = host.alias.clone();
if let Some(hint) = stale_hint {
app.notify_warning(crate::messages::stale_host(&hint));
}
app.set_screen(Screen::ConfirmDelete { alias });
}
}
KeyCode::Char('c') => actions::clone_selected(app),
KeyCode::Char('y') => {
if app.is_pattern_selected() {
return;
}
if let Some(host) = app.selected_host() {
let cmd = host.ssh_command(app.reload.config_path());
let alias = host.alias.clone();
match clipboard::copy_to_clipboard(&cmd) {
Ok(()) => {
app.notify(crate::messages::copied_ssh_command(&alias));
}
Err(e) => {
app.notify_error(e);
}
}
}
}
KeyCode::Char('x') => {
if app.is_pattern_selected() {
return;
}
if let Some(host) = app.selected_host() {
let alias = host.alias.clone();
if let Some(block) = serialize_host_block(
&app.hosts_state.ssh_config().elements,
&alias,
app.hosts_state.ssh_config().crlf,
) {
match clipboard::copy_to_clipboard(&block) {
Ok(()) => {
app.notify(crate::messages::copied_config_block(&alias));
}
Err(e) => {
app.notify_error(e);
}
}
}
}
}
KeyCode::Char('p') => {
if app.is_pattern_selected() {
return;
}
if !app.ping.status_is_empty() {
log::debug!(
"[purple] p: clearing {} ping result(s) + timestamps",
app.ping.status_len()
);
app.ping.clear_results();
app.clear_status();
} else {
super::ping::ping_selected_host(app, events_tx, true);
}
}
KeyCode::Char('P') => {
if !app.ping.status_is_empty() {
log::debug!(
"[purple] P: clearing {} ping result(s) + timestamps",
app.ping.status_len()
);
app.ping.clear_results();
app.clear_status();
} else {
let hosts_to_ping: Vec<(String, String, u16)> = app
.hosts_state
.list()
.iter()
.filter(|h| !h.hostname.is_empty() && h.proxy_jump.is_empty())
.map(|h| (h.alias.clone(), h.hostname.clone(), h.port))
.collect();
for h in app.hosts_state.list() {
if !h.proxy_jump.is_empty() {
app.ping
.insert_status(h.alias.clone(), crate::app::PingStatus::Checking);
}
}
if !hosts_to_ping.is_empty() {
for (alias, _, _) in &hosts_to_ping {
app.ping
.insert_status(alias.clone(), crate::app::PingStatus::Checking);
}
app.notify_info(crate::messages::PINGING_ALL);
crate::ping::ping_all(&hosts_to_ping, events_tx.clone(), app.ping.generation());
}
}
}
KeyCode::Char('!') => {
if app.ping.status_is_empty() {
app.notify_warning(crate::messages::PING_FIRST);
} else {
app.ping.set_filter_down_only(!app.ping.filter_down_only());
if app.ping.filter_down_only() {
if app.search.query().is_none() {
app.search.set_query(Some(String::new()));
}
app.apply_filter();
let count = app.search.filtered_indices().len();
app.notify(crate::messages::showing_unreachable(count));
} else {
if app.search.query().is_some_and(|q| q.is_empty()) {
app.search.set_query(None);
app.search.clear_filtered_indices();
app.search.clear_filtered_pattern_indices();
} else {
app.apply_filter();
}
app.clear_status();
}
}
}
KeyCode::Char('/') => {
app.start_search();
}
KeyCode::Char('K') => {
app.scan_keys();
app.set_screen(Screen::KeyList);
}
KeyCode::Char('t') => {
if !app.hosts_state.multi_select().is_empty() {
if !app.open_bulk_tag_editor() {
app.notify_warning(crate::messages::NO_HOSTS_TO_TAG);
}
return;
}
if app.is_pattern_selected() {
return;
}
if let Some(host) = app.selected_host() {
if let Some(ref source) = host.source_file {
let alias = host.alias.clone();
let path = source.display();
app.notify_error(crate::messages::included_host_tag_there(&alias, &path));
return;
}
let current_tags = host.tags.join(", ");
app.tags.open_tag_input(current_tags);
}
}
KeyCode::Char('s') => {
app.hosts_state.advance_sort_mode();
app.apply_sort();
if let Err(e) = preferences::save_sort_mode(app.hosts_state.sort_mode()) {
app.notify_error(crate::messages::sorted_by_save_failed(
app.hosts_state.sort_mode().label(),
&e,
));
} else {
app.notify(crate::messages::sorted_by(
app.hosts_state.sort_mode().label(),
));
}
}
KeyCode::Char('g') => {
use crate::app::GroupBy;
match app.hosts_state.group_by() {
GroupBy::None => {
app.hosts_state.set_group_by(GroupBy::Provider);
app.apply_sort();
if let Err(e) = preferences::save_group_by(app.hosts_state.group_by()) {
app.notify_error(crate::messages::grouped_by_save_failed(
&app.hosts_state.group_by().label(),
&e,
));
} else {
app.notify(crate::messages::grouped_by(
&app.hosts_state.group_by().label(),
));
}
}
GroupBy::Provider => {
let user_tags: Vec<String> = {
let mut seen = std::collections::HashSet::new();
let mut tags = Vec::new();
for host in app.hosts_state.list() {
for tag in &host.tags {
if seen.insert(tag.clone()) {
tags.push(tag.clone());
}
}
}
tags.sort_by_cached_key(|a| a.to_lowercase());
tags
};
if user_tags.is_empty() {
app.hosts_state.set_group_by(GroupBy::None);
app.apply_sort();
if let Err(e) = preferences::save_group_by(app.hosts_state.group_by()) {
app.notify_error(crate::messages::ungrouped_save_failed(&e));
} else {
app.notify(crate::messages::UNGROUPED);
}
} else {
app.hosts_state.set_group_by(GroupBy::Tag(String::new()));
app.apply_sort();
if let Err(e) = preferences::save_group_by(app.hosts_state.group_by()) {
app.notify_error(crate::messages::grouped_by_tag_save_failed(&e));
} else {
app.notify(crate::messages::GROUPED_BY_TAG);
}
}
}
GroupBy::Tag(_) => {
app.hosts_state.set_group_by(GroupBy::None);
app.apply_sort();
if let Err(e) = preferences::save_group_by(app.hosts_state.group_by()) {
app.notify_error(crate::messages::ungrouped_save_failed(&e));
} else {
app.notify(crate::messages::UNGROUPED);
}
}
}
}
KeyCode::Char('i') => {
if app.is_pattern_selected() {
return;
}
if let Some(index) = app.selected_host_index() {
app.set_screen(Screen::HostDetail { index });
}
}
KeyCode::Char('v') => {
app.hosts_state.toggle_view_mode();
app.ui.set_detail_toggle_pending(true);
app.ui.set_detail_scroll(0);
if let Err(e) = preferences::save_view_mode(app.hosts_state.view_mode()) {
log::warn!("[config] Failed to persist view mode: {e}");
}
}
KeyCode::Char(']') if app.hosts_state.view_mode() == ViewMode::Detailed => {
app.ui
.set_detail_scroll(app.ui.detail_scroll().saturating_add(1));
}
KeyCode::Char('[') if app.hosts_state.view_mode() == ViewMode::Detailed => {
app.ui
.set_detail_scroll(app.ui.detail_scroll().saturating_sub(1));
}
KeyCode::Char('u') => {
if let Some(snapshot) = app.forms.take_bulk_tag_undo() {
let config_backup = app.hosts_state.ssh_config().clone();
for (alias, tags) in &snapshot {
let _ = app.hosts_state.ssh_config_mut().set_host_tags(alias, tags);
}
if let Err(e) = app.hosts_state.ssh_config().write() {
app.hosts_state.set_ssh_config(config_backup);
app.forms.set_bulk_tag_undo(Some(snapshot));
app.notify_error(crate::messages::failed_to_save(&e));
} else {
let count = snapshot.len();
app.update_last_modified();
app.reload_hosts();
app.notify(crate::messages::restored_tags(count));
}
} else if let Some(deleted) = app.hosts_state.pop_undo() {
let alias = match &deleted.element {
ConfigElement::HostBlock(block) => block.host_pattern.clone(),
_ => "host".to_string(),
};
app.hosts_state
.ssh_config_mut()
.insert_host_at(deleted.element, deleted.position);
if let Err(e) = app.hosts_state.ssh_config().write() {
if let Some((element, position)) = app
.hosts_state
.ssh_config_mut()
.delete_host_undoable(&alias)
{
app.hosts_state
.undo_stack_mut()
.push(crate::app::DeletedHost { element, position });
}
app.notify_error(crate::messages::failed_to_save(&e));
} else {
app.update_last_modified();
app.reload_hosts();
app.container_state.queue_fetch(alias.clone());
app.notify(crate::messages::host_restored(&alias));
}
} else {
app.notify_warning(crate::messages::NOTHING_TO_UNDO);
}
}
KeyCode::Char('#') => {
app.open_tag_picker();
}
KeyCode::Char('m') => {
let current = crate::ui::theme::current_theme().name;
let builtins = crate::ui::theme::ThemeDef::builtins();
let custom = crate::ui::theme::ThemeDef::load_custom();
let idx = builtins
.iter()
.position(|t| t.name.eq_ignore_ascii_case(¤t))
.or_else(|| {
if custom.is_empty() {
None
} else {
custom
.iter()
.position(|t| t.name.eq_ignore_ascii_case(¤t))
.map(|i| builtins.len() + 1 + i) }
})
.unwrap_or(0);
app.ui.theme_picker_mut().list.select(Some(idx));
app.ui.theme_picker_mut().builtins = builtins;
app.ui.theme_picker_mut().custom = custom;
app.ui.theme_picker_mut().saved_name =
crate::preferences::load_theme().unwrap_or_else(|| "Purple".to_string());
app.ui.theme_picker_mut().original = Some(crate::ui::theme::current_theme());
app.set_screen(Screen::ThemePicker);
}
KeyCode::Char('T') => {
if app.is_pattern_selected() {
return;
}
if let Some(host) = app.selected_host() {
let stale_hint = super::host_form::stale_hint_for(host);
let alias = host.alias.clone();
if let Some(hint) = stale_hint {
app.notify_warning(crate::messages::stale_host(&hint));
}
app.refresh_tunnel_list(&alias);
*app.ui.tunnel_list_state_mut() = ratatui::widgets::ListState::default();
if !app.tunnels.list().is_empty() {
app.ui.tunnel_list_state_mut().select(Some(0));
}
app.set_screen(Screen::TunnelList { alias });
}
}
KeyCode::Char('S') => {
if !app.demo_mode {
*app.providers.config_mut() = crate::providers::config::ProviderConfig::load();
}
*app.ui.provider_list_state_mut() = ratatui::widgets::ListState::default();
app.ui.provider_list_state_mut().select(Some(0));
app.set_screen(Screen::Providers);
}
KeyCode::Char('I') => {
let count = crate::import::count_known_hosts_candidates();
if count > 0 {
app.set_screen(Screen::ConfirmImport { count });
} else {
app.notify_warning(crate::messages::NO_IMPORTABLE_HOSTS);
}
}
KeyCode::Char('X') => {
let stale = app.hosts_state.ssh_config().stale_hosts();
if stale.is_empty() {
app.notify_warning(crate::messages::NO_STALE_HOSTS);
} else {
let aliases: Vec<String> = stale.into_iter().map(|(a, _)| a).collect();
app.set_screen(Screen::ConfirmPurgeStale {
aliases,
provider: None,
});
}
}
KeyCode::Char('V') => actions::initiate_bulk_vault_sign(app),
KeyCode::Char(' ') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if app.is_pattern_selected() {
return;
}
if let Some(idx) = app.selected_host_index() {
app.hosts_state.toggle_multi_select(idx);
}
}
KeyCode::Char(' ') => {
if app.is_pattern_selected() {
return;
}
if let Some(idx) = app.selected_host_index() {
app.hosts_state.toggle_multi_select(idx);
}
}
KeyCode::Char('r') => {
if app.is_pattern_selected() {
return;
}
let (aliases, stale_hint): (Vec<String>, Option<String>) =
if app.hosts_state.multi_select().is_empty() {
if let Some(host) = app.selected_host() {
let hint = super::host_form::stale_hint_for(host);
(vec![host.alias.clone()], hint)
} else {
(Vec::new(), None)
}
} else {
let has_stale = app.hosts_state.multi_select().iter().any(|&idx| {
app.hosts_state
.list()
.get(idx)
.is_some_and(|h| h.stale.is_some())
});
(
app.hosts_state
.multi_select()
.iter()
.filter_map(|&idx| {
app.hosts_state.list().get(idx).map(|h| h.alias.clone())
})
.collect(),
if has_stale {
Some(" Selection includes stale hosts.".to_string())
} else {
None
},
)
};
if let Some(hint) = stale_hint {
app.notify_warning(crate::messages::stale_host(&hint));
}
if aliases.is_empty() {
app.notify_warning(crate::messages::NO_HOST_SELECTED);
} else {
super::snippet::open_snippet_picker(app, aliases);
}
}
KeyCode::Char('R') => {
if app.is_pattern_selected() {
return;
}
let aliases: Vec<String> = app
.hosts_state
.display_list()
.iter()
.filter_map(|item| match item {
crate::app::HostListItem::Host { index } => {
Some(app.hosts_state.list()[*index].alias.clone())
}
_ => None,
})
.collect();
if aliases.is_empty() {
app.notify_warning(crate::messages::NO_HOSTS_TO_RUN);
} else {
super::snippet::open_snippet_picker(app, aliases);
}
}
KeyCode::Char(':') => {
log::debug!("jump: opened from host list");
app.open_jump(crate::app::JumpMode::Hosts);
}
KeyCode::Char('F') => actions::open_file_browser(app, events_tx),
KeyCode::Char('C') => actions::open_container_overlay(app, events_tx),
KeyCode::Char('n') if app.search.query().is_none() => {
log::debug!("[purple] opening whats-new overlay via n");
super::whats_new::dismiss_whats_new_toast(app);
app.set_screen(Screen::WhatsNew(crate::app::WhatsNewState::default()));
}
KeyCode::Char('?') => {
let old = std::mem::replace(&mut app.screen, Screen::HostList);
app.set_screen(Screen::Help {
return_screen: Box::new(old),
});
}
_ => {}
}
}
pub(super) fn handle_search_key(app: &mut App, key: KeyEvent, events_tx: &mpsc::Sender<AppEvent>) {
match key.code {
KeyCode::Esc => {
app.cancel_search();
}
KeyCode::Enter => {
if let Some(host) = app.selected_host() {
let alias = host.alias.clone();
let askpass = host.askpass.clone();
let stale_hint = super::host_form::stale_hint_for(host);
app.cancel_search();
if let Some(hint) = stale_hint {
app.notify_warning(crate::messages::stale_host(&hint));
}
if app.demo_mode {
app.notify_warning(crate::messages::DEMO_CONNECTION_DISABLED);
return;
}
app.ui.queue_connect(alias, askpass);
}
}
KeyCode::Down | KeyCode::Tab => {
app.select_next();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::Up | KeyCode::BackTab => {
app.select_prev();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::PageDown => {
app.page_down_host();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::PageUp => {
app.page_up_host();
super::ping::refresh_selected_if_stale(app, events_tx);
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !app.ping.status_is_empty() {
log::debug!(
"[purple] ctrl+p: clearing {} ping result(s) + timestamps",
app.ping.status_len()
);
let was_filtering = app.ping.filter_down_only();
app.ping.clear_results();
if was_filtering {
app.cancel_search();
}
app.clear_status();
} else {
super::ping::ping_selected_host(app, events_tx, false);
}
}
KeyCode::Char(' ') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(idx) = app.selected_host_index() {
app.hosts_state.toggle_multi_select(idx);
}
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let visible_indices: Vec<usize> = app.search.filtered_indices().to_vec();
let all_selected = !visible_indices.is_empty()
&& visible_indices
.iter()
.all(|idx| app.hosts_state.multi_select().contains(idx));
if all_selected {
app.hosts_state.clear_multi_select();
} else {
for idx in visible_indices {
app.hosts_state.multi_select_mut().insert(idx);
}
}
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(host) = app.selected_host().cloned() {
let hint = super::host_form::stale_hint_for(&host);
app.open_host_edit_form(host, hint);
}
}
KeyCode::Char('!') if app.ping.filter_down_only() => {
app.ping.set_filter_down_only(false);
if app.search.query().is_some_and(|q| q.is_empty()) {
app.cancel_search();
} else {
app.apply_filter();
}
app.clear_status();
}
KeyCode::Char(c) => {
app.search.push_query_char(c);
app.apply_filter();
}
KeyCode::Backspace => {
app.search.pop_query_char();
app.apply_filter();
}
_ => {}
}
}