use anyhow::anyhow;
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
use kubectl_view_allocations::GroupBy;
use serde::Serialize;
use std::{
fs,
path::{Path, PathBuf},
};
use crate::{
app::{
actions::{InputAction, InputModal, InputSubmit, Modal, ResourceAction},
key_binding::DEFAULT_KEYBINDING,
models::{
HasPodSelector, KubeResource, Scrollable, ScrollableTxt, StatefulList, StatefulTable,
},
secrets::KubeSecret,
troubleshoot::ResourceKind,
ActiveBlock, App, PendingEdit, PendingShellExec, Route, RouteId,
},
cmd::IoCmdEvent,
event::Key,
network::{stream::IoStreamEvent, IoEvent, ResourcePatch},
};
macro_rules! handle_workload_action {
($key:expr, $app:expr, $field:ident, $kind:expr) => {
if $key == DEFAULT_KEYBINDING.aggregate_logs.key {
if let Some(res) = $app.data.$field.get_selected_item_copy() {
if let Some(selector) = res.pod_label_selector() {
$app
.dispatch_aggregate_logs(
res.name.clone(),
res.namespace.clone(),
selector,
$kind.to_owned(),
RouteId::Home,
)
.await;
}
}
} else if let Some(res) = handle_block_action($key, &$app.data.$field) {
let ok = handle_describe_decode_or_yaml_action(
$key,
$app,
&res,
IoCmdEvent::GetDescribe {
kind: $kind.to_owned(),
value: res.name.to_owned(),
ns: Some(res.namespace.to_owned()),
},
)
.await;
if !ok {
if let Some(selector) = res.pod_label_selector() {
$app
.dispatch_resource_pods(
res.namespace.clone(),
selector,
$kind.to_owned(),
RouteId::Home,
)
.await;
}
}
}
};
}
macro_rules! handle_resource_action {
($match_expr:expr, $key:expr, $app:expr,
namespaced: [ $(($block:path, $field:ident, $kind:expr)),* $(,)? ],
cluster: [ $(($cblock:path, $cfield:ident, $ckind:expr)),* $(,)? ],
extra: { $($extra_arms:tt)* }
) => {
match $match_expr {
$(
$block => {
if let Some(res) = handle_block_action($key, &$app.data.$field) {
handle_leaf_resource_action(
$key,
$app,
&res,
$kind.to_owned(),
Some(res.namespace.to_owned()),
)
.await;
}
}
)*
$(
$cblock => {
if let Some(res) = handle_block_action($key, &$app.data.$cfield) {
handle_leaf_resource_action($key, $app, &res, $ckind.to_owned(), None).await;
}
}
)*
$($extra_arms)*
}
};
}
macro_rules! handle_resource_scroll {
($match_expr:expr, $app:expr, $event:expr, $is_mouse:expr,
[ $(($block:path, $field:ident)),* $(,)? ],
extra: { $($extra_arms:tt)* }
) => {
match $match_expr {
$(
$block => $app.data.$field.handle_scroll($event),
)*
$($extra_arms)*
}
};
}
pub async fn handle_key_events(key: Key, key_event: KeyEvent, app: &mut App) {
let _ = key_event;
if app.modal.is_some() {
handle_modal_key(key, app).await;
return;
}
if app.input_modal.is_some() {
handle_input_modal_key(key, app).await;
return;
}
if app.action_menu.is_some() {
handle_action_menu_key(key, app).await;
return;
}
if app.show_port_forwards {
handle_port_forwards_key(key, app).await;
return;
}
let resource_filter_active = app
.current_resource_table()
.is_some_and(|table| table.is_filter_active());
if app.is_menu_active() && app.menu_filter_active && handle_menu_filter_key(key, app) {
} else if app.is_menu_active() && !app.menu_filter_active && key == DEFAULT_KEYBINDING.filter.key
{
app.menu_filter_active = true;
} else if resource_filter_active && handle_resource_filter_key(key, app) {
} else if app.get_current_route().active_block == ActiveBlock::Namespaces
&& app.ns_filter_active
&& handle_namespace_filter_key(key, app)
{
} else {
match key {
_ if key == DEFAULT_KEYBINDING.esc.key => {
handle_escape(app);
}
_ if key == DEFAULT_KEYBINDING.quit.key || key == DEFAULT_KEYBINDING.quit.alt.unwrap() => {
app.should_quit = true;
}
_ if key == DEFAULT_KEYBINDING.up.key
|| key == DEFAULT_KEYBINDING.up.alt.unwrap()
|| key == Key::Up =>
{
handle_block_scroll(app, ScrollEvent::up(), false).await;
}
_ if key == DEFAULT_KEYBINDING.down.key
|| key == DEFAULT_KEYBINDING.down.alt.unwrap()
|| key == Key::Down =>
{
handle_block_scroll(app, ScrollEvent::down(), false).await;
}
_ if key == DEFAULT_KEYBINDING.pg_up.key => {
handle_block_scroll(app, ScrollEvent::Relative(-10), false).await;
}
_ if key == DEFAULT_KEYBINDING.pg_down.key => {
handle_block_scroll(app, ScrollEvent::Relative(10), false).await;
}
_ if key == DEFAULT_KEYBINDING.home.key => {
handle_block_scroll(app, ScrollEvent::Absolute(0), false).await;
}
_ if key == DEFAULT_KEYBINDING.end.key => {
handle_block_scroll(app, ScrollEvent::End, false).await;
}
_ if key == DEFAULT_KEYBINDING.toggle_log_timestamps.key
&& app.get_current_route().active_block == ActiveBlock::Logs =>
{
app.log_timestamps = !app.log_timestamps;
app.restream_logs().await;
}
_ if key == DEFAULT_KEYBINDING.toggle_log_wrap.key
&& app.get_current_route().active_block == ActiveBlock::Logs =>
{
app.log_wrap = !app.log_wrap;
}
_ if key == DEFAULT_KEYBINDING.toggle_theme.key => {
app.cycle_theme();
app.set_status_message(format!("Theme: {}", app.palette.name));
}
_ if key == DEFAULT_KEYBINDING.cycle_theme_prev.key => {
app.cycle_theme_prev();
app.set_status_message(format!("Theme: {}", app.palette.name));
}
_ if key == DEFAULT_KEYBINDING.toggle_wide_columns.key => {
app.wide_columns = !app.wide_columns;
}
_ if key == DEFAULT_KEYBINDING.refresh.key => {
app.refresh();
}
_ if key == DEFAULT_KEYBINDING.dump_error_log.key => {
dump_error_history(app, None);
}
_ if key == DEFAULT_KEYBINDING.help.key => {
if app.get_current_route().active_block != ActiveBlock::Help {
app.help_scroll = 0;
app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::Help);
}
}
_ if key == DEFAULT_KEYBINDING.jump_to_all_context.key => {
app.route_contexts();
}
_ if key == DEFAULT_KEYBINDING.jump_to_current_context.key => {
app.route_home();
}
_ if key == DEFAULT_KEYBINDING.jump_to_utilization.key => {
app.route_utilization();
}
_ if key == DEFAULT_KEYBINDING.jump_to_troubleshoot.key => {
app.route_troubleshoot();
}
_ if key == DEFAULT_KEYBINDING.cycle_main_views.key => {
app.cycle_main_routes();
}
_ if key == DEFAULT_KEYBINDING.cycle_main_views_prev.key => {
app.cycle_main_routes_prev();
}
_ if key == DEFAULT_KEYBINDING.reset_navigation.key => {
app.reset_navigation();
}
_ if key == DEFAULT_KEYBINDING.open_action_menu.key => {
let block = app.get_current_route().active_block;
app.open_action_menu(block);
}
_ if key == DEFAULT_KEYBINDING.port_forwards_list.key => {
app.open_port_forwards();
}
_ => handle_route_events(key, app).await,
}
}
}
async fn handle_modal_key(key: Key, app: &mut App) {
if key == Key::Char('y') || key == DEFAULT_KEYBINDING.submit.key {
if let Some(modal) = app.modal.take() {
app.dispatch(modal.on_confirm).await;
app.tick_count = 0;
}
} else if key == Key::Char('n') || key == DEFAULT_KEYBINDING.esc.key {
app.close_modal();
}
}
async fn handle_input_modal_key(key: Key, app: &mut App) {
if key == DEFAULT_KEYBINDING.submit.key {
let result = app.input_modal.as_ref().map(|input| input.validate());
match result {
Some(Ok(InputSubmit::Confirm(modal))) => {
app.close_input_modal();
app.open_modal(modal);
}
Some(Ok(InputSubmit::StartPortForward {
kind,
namespace,
name,
local_port,
remote_port,
})) => {
app.close_input_modal();
app
.start_port_forward(kind, namespace, name, local_port, remote_port)
.await;
}
Some(Err(err)) => {
if let Some(input) = app.input_modal.as_mut() {
input.error = Some(err);
}
}
None => {}
}
} else if key == DEFAULT_KEYBINDING.esc.key {
app.close_input_modal();
} else if let Some(input) = app.input_modal.as_mut() {
match key {
Key::Char(c) => {
input.buffer.push(c);
input.error = None;
}
Key::Backspace => {
input.buffer.pop();
input.error = None;
}
_ => {}
}
}
}
fn selected_target(app: &App, block: ActiveBlock) -> Option<(String, Option<String>)> {
macro_rules! namespaced {
($field:ident) => {
app
.data
.$field
.get_selected_item_copy()
.map(|res| (res.name.clone(), Some(res.namespace.clone())))
};
}
macro_rules! cluster {
($field:ident) => {
app
.data
.$field
.get_selected_item_copy()
.map(|res| (res.name.clone(), None))
};
}
match block {
ActiveBlock::Pods => namespaced!(pods),
ActiveBlock::Services => namespaced!(services),
ActiveBlock::ConfigMaps => namespaced!(config_maps),
ActiveBlock::Secrets => namespaced!(secrets),
ActiveBlock::StatefulSets => namespaced!(stateful_sets),
ActiveBlock::ReplicaSets => namespaced!(replica_sets),
ActiveBlock::Deployments => namespaced!(deployments),
ActiveBlock::Jobs => namespaced!(jobs),
ActiveBlock::DaemonSets => namespaced!(daemon_sets),
ActiveBlock::CronJobs => namespaced!(cronjobs),
ActiveBlock::ReplicationControllers => namespaced!(replication_controllers),
ActiveBlock::Roles => namespaced!(roles),
ActiveBlock::RoleBindings => namespaced!(role_bindings),
ActiveBlock::Ingresses => namespaced!(ingress),
ActiveBlock::PersistentVolumeClaims => namespaced!(persistent_volume_claims),
ActiveBlock::NetworkPolicies => namespaced!(network_policies),
ActiveBlock::ServiceAccounts => namespaced!(service_accounts),
ActiveBlock::Events => namespaced!(events),
ActiveBlock::Nodes => cluster!(nodes),
ActiveBlock::PersistentVolumes => cluster!(persistent_volumes),
ActiveBlock::StorageClasses => cluster!(storage_classes),
ActiveBlock::ClusterRoles => cluster!(cluster_roles),
ActiveBlock::ClusterRoleBindings => cluster!(cluster_role_bindings),
ActiveBlock::DynamicResource => app
.data
.dynamic_resources
.get_selected_item_copy()
.map(|res| (res.name.clone(), res.namespace.clone())),
_ => None,
}
}
fn resource_kind_label(app: &App, block: ActiveBlock) -> String {
let label = match block {
ActiveBlock::Pods => "pod",
ActiveBlock::Services => "service",
ActiveBlock::ConfigMaps => "configmap",
ActiveBlock::Secrets => "secret",
ActiveBlock::StatefulSets => "statefulset",
ActiveBlock::ReplicaSets => "replicaset",
ActiveBlock::Deployments => "deployment",
ActiveBlock::Jobs => "job",
ActiveBlock::DaemonSets => "daemonset",
ActiveBlock::CronJobs => "cronjob",
ActiveBlock::ReplicationControllers => "replicationcontroller",
ActiveBlock::Roles => "role",
ActiveBlock::RoleBindings => "rolebinding",
ActiveBlock::Ingresses => "ingress",
ActiveBlock::PersistentVolumeClaims => "persistentvolumeclaim",
ActiveBlock::NetworkPolicies => "networkpolicy",
ActiveBlock::ServiceAccounts => "serviceaccount",
ActiveBlock::Events => "event",
ActiveBlock::Nodes => "node",
ActiveBlock::PersistentVolumes => "persistentvolume",
ActiveBlock::StorageClasses => "storageclass",
ActiveBlock::ClusterRoles => "clusterrole",
ActiveBlock::ClusterRoleBindings => "clusterrolebinding",
ActiveBlock::DynamicResource => {
return app
.data
.selected
.dynamic_kind
.as_ref()
.map(|kind| kind.kind.to_lowercase())
.unwrap_or_else(|| "resource".to_owned());
}
_ => "resource",
};
label.to_owned()
}
async fn handle_delete_resource(app: &mut App) {
let block = app.get_current_route().active_block;
let Some((name, namespace)) = selected_target(app, block) else {
return;
};
let kind = resource_kind_label(app, block);
let prompt = match &namespace {
Some(ns) => format!(
"Delete {} '{}' in namespace '{}'? This cannot be undone.",
kind, name, ns
),
None => format!("Delete {} '{}'? This cannot be undone.", kind, name),
};
app.open_modal(Modal::confirm(
"Confirm delete",
prompt,
IoEvent::DeleteResource {
block,
name,
namespace,
},
));
}
async fn handle_restart_resource(app: &mut App) {
let block = app.get_current_route().active_block;
if !matches!(
block,
ActiveBlock::Deployments | ActiveBlock::StatefulSets | ActiveBlock::DaemonSets
) {
return;
}
let Some((name, namespace)) = selected_target(app, block) else {
return;
};
let kind = resource_kind_label(app, block);
let prompt = match &namespace {
Some(ns) => format!("Rollout restart {} '{}' in namespace '{}'?", kind, name, ns),
None => format!("Rollout restart {} '{}'?", kind, name),
};
app.open_modal(Modal::confirm(
"Confirm restart",
prompt,
IoEvent::PatchResource {
block,
name,
namespace,
patch: ResourcePatch::RolloutRestart,
},
));
}
fn is_scalable(block: ActiveBlock) -> bool {
matches!(
block,
ActiveBlock::Deployments
| ActiveBlock::StatefulSets
| ActiveBlock::ReplicaSets
| ActiveBlock::ReplicationControllers
)
}
fn current_replicas(app: &App, block: ActiveBlock) -> Option<i32> {
match block {
ActiveBlock::Deployments => app
.data
.deployments
.get_selected_item_copy()
.and_then(|r| r.get_k8s_obj().spec.as_ref().and_then(|s| s.replicas)),
ActiveBlock::StatefulSets => app
.data
.stateful_sets
.get_selected_item_copy()
.and_then(|r| r.get_k8s_obj().spec.as_ref().and_then(|s| s.replicas)),
ActiveBlock::ReplicaSets => app
.data
.replica_sets
.get_selected_item_copy()
.and_then(|r| r.get_k8s_obj().spec.as_ref().and_then(|s| s.replicas)),
ActiveBlock::ReplicationControllers => app
.data
.replication_controllers
.get_selected_item_copy()
.and_then(|r| r.get_k8s_obj().spec.as_ref().and_then(|s| s.replicas)),
_ => None,
}
}
fn handle_scale_resource(app: &mut App) {
let block = app.get_current_route().active_block;
if !is_scalable(block) {
return;
}
let Some((name, namespace)) = selected_target(app, block) else {
return;
};
let kind = resource_kind_label(app, block);
let buffer = current_replicas(app, block)
.map(|n| n.to_string())
.unwrap_or_default();
app.open_input_modal(InputModal {
title: "Scale".to_owned(),
prompt: format!("New replica count for {} '{}':", kind, name),
buffer,
error: None,
action: InputAction::Scale {
block,
name,
namespace,
kind,
},
});
}
async fn handle_action_menu_key(key: Key, app: &mut App) {
match key {
_ if key == DEFAULT_KEYBINDING.esc.key => app.close_action_menu(),
_ if key == DEFAULT_KEYBINDING.up.key
|| key == DEFAULT_KEYBINDING.up.alt.unwrap()
|| key == Key::Up =>
{
if let Some(menu) = app.action_menu.as_mut() {
menu.handle_scroll(ScrollEvent::up());
}
}
_ if key == DEFAULT_KEYBINDING.down.key
|| key == DEFAULT_KEYBINDING.down.alt.unwrap()
|| key == Key::Down =>
{
if let Some(menu) = app.action_menu.as_mut() {
menu.handle_scroll(ScrollEvent::down());
}
}
_ if key == DEFAULT_KEYBINDING.submit.key => {
let selected = app.action_menu.as_ref().and_then(|menu| {
menu
.state
.selected()
.and_then(|i| menu.items.get(i).copied())
});
app.close_action_menu();
if let Some(action) = selected {
execute_resource_action(action, app).await;
}
}
_ => {}
}
}
async fn execute_resource_action(action: ResourceAction, app: &mut App) {
match action {
ResourceAction::Scale => handle_scale_resource(app),
ResourceAction::Cordon => handle_cordon_toggle(app).await,
ResourceAction::Suspend => handle_cronjob_suspend_toggle(app).await,
ResourceAction::Trigger => handle_cronjob_trigger(app).await,
other => {
let block = app.get_current_route().active_block;
if let Some(key) = other.hotkey(block) {
handle_route_events(key, app).await;
}
}
}
}
async fn handle_cronjob_suspend_toggle(app: &mut App) {
let Some(cronjob) = app.data.cronjobs.get_selected_item_copy() else {
return;
};
let suspend = !cronjob.suspend;
let verb = if suspend { "Suspend" } else { "Resume" };
app.open_modal(Modal::confirm(
"Confirm suspend",
format!(
"{} cronjob '{}' in namespace '{}'?",
verb, cronjob.name, cronjob.namespace
),
IoEvent::PatchResource {
block: ActiveBlock::CronJobs,
name: cronjob.name.clone(),
namespace: Some(cronjob.namespace.clone()),
patch: ResourcePatch::SetSuspend(suspend),
},
));
}
async fn handle_cronjob_trigger(app: &mut App) {
let Some(cronjob) = app.data.cronjobs.get_selected_item_copy() else {
return;
};
app.open_modal(Modal::confirm(
"Confirm trigger",
format!(
"Trigger cronjob '{}' now? This creates a one-off Job.",
cronjob.name
),
IoEvent::TriggerCronJob {
name: cronjob.name.clone(),
namespace: cronjob.namespace.clone(),
},
));
}
async fn handle_cordon_toggle(app: &mut App) {
let Some(node) = app.data.nodes.get_selected_item_copy() else {
return;
};
let currently_unschedulable = node
.get_k8s_obj()
.spec
.as_ref()
.and_then(|spec| spec.unschedulable)
.unwrap_or(false);
let cordon = !currently_unschedulable;
let verb = if cordon { "Cordon" } else { "Uncordon" };
app.open_modal(Modal::confirm(
"Confirm cordon",
format!("{} node '{}'?", verb, node.name),
IoEvent::PatchResource {
block: ActiveBlock::Nodes,
name: node.name.clone(),
namespace: None,
patch: ResourcePatch::SetUnschedulable(cordon),
},
));
}
pub async fn handle_mouse_events(mouse: MouseEvent, app: &mut App) {
match mouse.kind {
MouseEventKind::ScrollDown => handle_block_scroll(app, ScrollEvent::down(), true).await,
MouseEventKind::ScrollUp => handle_block_scroll(app, ScrollEvent::up(), true).await,
_ => {}
}
}
fn handle_escape(app: &mut App) {
if !app.api_error.is_empty() {
app.api_error = String::default();
} else if !app.status_message.is_empty() {
app.clear_status_message();
}
if app.is_menu_active() && app.menu_filter_active {
clear_or_deactivate_filter(&mut app.menu_filter, &mut app.menu_filter_active);
return;
}
if app.get_current_route().active_block == ActiveBlock::Namespaces && app.ns_filter_active {
clear_or_deactivate_filter(&mut app.ns_filter, &mut app.ns_filter_active);
return;
}
if let Some((filter, filter_active, _)) = app.current_resource_filter_mut() {
if *filter_active {
clear_or_deactivate_filter(filter, filter_active);
return;
}
}
if app.is_menu_active() {
app.menu_filter.clear();
app.menu_filter_active = false;
}
match app.get_current_route().id {
RouteId::HelpMenu => {
app.pop_navigation_stack();
}
_ => match app.get_current_route().active_block {
ActiveBlock::Namespaces
| ActiveBlock::Containers
| ActiveBlock::Yaml
| ActiveBlock::Describe => {
app.pop_navigation_stack();
}
ActiveBlock::Pods if app.data.selected.pod_selector.is_some() => {
app.data.selected.pod_selector = None;
app.data.selected.pod_selector_ns = None;
app.data.selected.pod_selector_resource = None;
app.pop_navigation_stack();
}
ActiveBlock::Logs => {
app.cancel_log_stream();
app.log_previous = false;
if app.data.selected.pod_selector.is_none() {
app.data.selected.pod_selector_resource = None;
}
app.pop_navigation_stack();
}
_ => {
if matches!(
app.get_prev_route().active_block,
ActiveBlock::More | ActiveBlock::DynamicView
) {
app.pop_navigation_stack();
}
}
},
}
}
fn handle_menu_filter_key(key: Key, app: &mut App) -> bool {
match key {
Key::Char(c) => {
app.menu_filter.push(c);
let menu = get_active_menu_mut(app);
menu.state.select(Some(0));
true
}
Key::Backspace => {
app.menu_filter.pop();
let menu = get_active_menu_mut(app);
menu.state.select(Some(0));
true
}
_ => false,
}
}
fn handle_filter_text_key(filter: &mut String, key: Key) -> bool {
match key {
Key::Char(c) => {
filter.push(c);
true
}
Key::Backspace => {
filter.pop();
true
}
_ => false,
}
}
fn clear_or_deactivate_filter(filter: &mut String, active: &mut bool) {
if filter.is_empty() {
*active = false;
} else {
filter.clear();
}
}
fn handle_resource_filter_key(key: Key, app: &mut App) -> bool {
if let Some((filter, _, state)) = app.current_resource_filter_mut() {
let handled = handle_filter_text_key(filter, key);
if handled {
state.select(Some(0));
}
handled
} else {
false
}
}
fn handle_namespace_filter_key(key: Key, app: &mut App) -> bool {
let handled = handle_filter_text_key(&mut app.ns_filter, key);
if handled {
app.data.namespaces.state.select(Some(0));
}
handled
}
fn get_active_menu_mut(app: &mut App) -> &mut StatefulList<(String, ActiveBlock)> {
match app.get_current_route().active_block {
ActiveBlock::DynamicView => &mut app.dynamic_resources_menu,
_ => &mut app.more_resources_menu,
}
}
pub fn filter_menu_items<'a>(
items: &'a [(String, ActiveBlock)],
filter: &str,
) -> Vec<(usize, &'a (String, ActiveBlock))> {
if filter.is_empty() {
return items.iter().enumerate().collect();
}
let filter_lower = filter.to_lowercase();
items
.iter()
.enumerate()
.filter(|(_, (name, _))| {
let name_lower = name.to_lowercase();
name_lower.contains(&filter_lower) || glob_match::glob_match(&filter_lower, &name_lower)
})
.collect()
}
async fn handle_describe_decode_or_yaml_action<T, S>(
key: Key,
app: &mut App,
res: &T,
action: IoCmdEvent,
) -> bool
where
T: KubeResource<S> + 'static,
S: Serialize,
{
if key == DEFAULT_KEYBINDING.describe_resource.key {
app.data.describe_out = ScrollableTxt::new();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
app.dispatch_cmd(action).await;
true
} else if key == DEFAULT_KEYBINDING.resource_yaml.key {
let yaml = res.resource_to_yaml();
app.data.describe_out = ScrollableTxt::with_string(yaml);
app.push_navigation_stack(RouteId::Home, ActiveBlock::Yaml);
true
} else if key == DEFAULT_KEYBINDING.decode_secret.key {
let of_any = res as &dyn std::any::Any;
if let Some(secret) = of_any.downcast_ref::<KubeSecret>() {
let display_output = secret.decode_secret();
app.data.describe_out = ScrollableTxt::with_string(display_output);
app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
true
} else {
false
}
} else {
false
}
}
async fn handle_leaf_resource_action<T, S>(
key: Key,
app: &mut App,
res: &T,
kind: String,
ns: Option<String>,
) where
T: KubeResource<S> + 'static,
S: Serialize,
{
let describe_action = IoCmdEvent::GetDescribe {
kind,
value: res.get_name().to_owned(),
ns,
};
let handled = handle_describe_decode_or_yaml_action(key, app, res, describe_action.clone()).await;
dispatch_describe_on_submit(key, app, handled, describe_action).await;
}
async fn dispatch_describe_on_submit(
key: Key,
app: &mut App,
handled: bool,
describe_action: IoCmdEvent,
) {
if !handled && key == DEFAULT_KEYBINDING.submit.key {
app.data.describe_out = ScrollableTxt::new();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
app.dispatch_cmd(describe_action).await;
}
}
async fn handle_route_events(key: Key, app: &mut App) {
match app.get_current_route().id {
RouteId::Home => {
match key {
_ if key == DEFAULT_KEYBINDING.right.key
|| key == DEFAULT_KEYBINDING.right.alt.unwrap()
|| key == Key::Right =>
{
app.deactivate_current_resource_filter();
app.context_tabs.next();
app.set_route(app.context_tabs.get_active_route().clone());
}
_ if key == DEFAULT_KEYBINDING.left.key
|| key == DEFAULT_KEYBINDING.left.alt.unwrap()
|| key == Key::Left =>
{
app.deactivate_current_resource_filter();
app.context_tabs.previous();
app.set_route(app.context_tabs.get_active_route().clone());
}
_ if key == DEFAULT_KEYBINDING.filter.key => {
if app.get_current_route().active_block == ActiveBlock::Namespaces {
app.ns_filter_active = true;
} else if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
*filter_active = true;
}
}
_ if key == DEFAULT_KEYBINDING.toggle_info.key => {
app.show_info_bar = !app.show_info_bar;
}
_ if key == DEFAULT_KEYBINDING.select_all_namespace.key => app.data.selected.ns = None,
_ if key == DEFAULT_KEYBINDING.delete_resource.key => {
handle_delete_resource(app).await;
}
_ if key == DEFAULT_KEYBINDING.restart_resource.key => {
handle_restart_resource(app).await;
}
_ if key == DEFAULT_KEYBINDING.edit_resource.key => {
queue_selected_resource_edit(app);
}
_ if key == DEFAULT_KEYBINDING.port_forward.key => {
open_port_forward_input(app);
}
_ if key == DEFAULT_KEYBINDING.jump_to_namespace.key
&& app.get_current_route().active_block != ActiveBlock::Namespaces =>
{
app.push_navigation_stack(RouteId::Home, ActiveBlock::Namespaces);
}
_ if key == DEFAULT_KEYBINDING.jump_to_pods.key => {
app.data.selected.pod_selector = None;
app.data.selected.pod_selector_ns = None;
app.data.selected.pod_selector_resource = None;
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(0).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_services.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(1).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_nodes.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(2).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_configmaps.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(3).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_statefulsets.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(4).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_replicasets.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(5).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_deployments.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(6).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_jobs.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(7).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_daemonsets.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(8).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_more_resources.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(9).route.clone();
app.set_route(route);
}
_ if key == DEFAULT_KEYBINDING.jump_to_dynamic_resources.key => {
app.deactivate_current_resource_filter();
let route = app.context_tabs.set_index(10).route.clone();
app.set_route(route);
}
_ => {}
};
handle_resource_action!(app.get_current_route().active_block, key, app,
namespaced: [
(ActiveBlock::Services, services, "service"),
(ActiveBlock::ConfigMaps, config_maps, "configmap"),
(ActiveBlock::Secrets, secrets, "secret"),
(ActiveBlock::Roles, roles, "roles"),
(ActiveBlock::RoleBindings, role_bindings, "rolebindings"),
(ActiveBlock::Ingresses, ingress, "ingress"),
(ActiveBlock::PersistentVolumeClaims, persistent_volume_claims, "persistentvolumeclaims"),
(ActiveBlock::ServiceAccounts, service_accounts, "serviceaccounts"),
(ActiveBlock::Events, events, "event"),
(ActiveBlock::NetworkPolicies, network_policies, "networkpolicy"),
],
cluster: [
(ActiveBlock::StorageClasses, storage_classes, "storageclass"),
(ActiveBlock::ClusterRoles, cluster_roles, "clusterroles"),
(ActiveBlock::ClusterRoleBindings, cluster_role_bindings, "clusterrolebinding"),
(ActiveBlock::PersistentVolumes, persistent_volumes, "persistentvolumes"),
],
extra: {
ActiveBlock::Nodes => {
if let Some(res) = handle_block_action(key, &app.data.nodes) {
let ok = handle_describe_decode_or_yaml_action(
key,
app,
&res,
IoCmdEvent::GetDescribe {
kind: "node".to_owned(),
value: res.name.to_owned(),
ns: None,
},
)
.await;
if !ok {
app.dispatch_node_pods(res.name.clone(), RouteId::Home).await;
}
}
}
ActiveBlock::Deployments => {
handle_workload_action!(key, app, deployments, "deployment");
}
ActiveBlock::StatefulSets => {
handle_workload_action!(key, app, stateful_sets, "statefulset");
}
ActiveBlock::ReplicaSets => {
handle_workload_action!(key, app, replica_sets, "replicaset");
}
ActiveBlock::Jobs => {
handle_workload_action!(key, app, jobs, "job");
}
ActiveBlock::DaemonSets => {
handle_workload_action!(key, app, daemon_sets, "daemonset");
}
ActiveBlock::CronJobs => {
handle_workload_action!(key, app, cronjobs, "cronjob");
}
ActiveBlock::ReplicationControllers => {
handle_workload_action!(key, app, replication_controllers, "replicationcontroller");
}
ActiveBlock::Namespaces => {
if let Some(ns) = handle_block_action(key, &app.data.namespaces) {
app.data.selected.ns = Some(ns.name);
app.cache_essential_data().await;
app.queue_background_resource_cache();
app.pop_navigation_stack();
}
}
ActiveBlock::Pods => {
if key == DEFAULT_KEYBINDING.aggregate_logs.key {
if let Some(pod) = app.data.pods.get_selected_item_copy() {
app.data.selected.pod = Some(pod.name.clone());
app.data.selected.pod_selector_resource = Some("pod".into());
app.data.containers.set_items(pod.containers);
app.dispatch_pod_logs(pod.name, RouteId::Home).await;
}
} else if key == DEFAULT_KEYBINDING.previous_logs.key {
handle_previous_logs_for_pod(app, RouteId::Home).await;
} else if let Some(pod) = handle_block_action(key, &app.data.pods) {
let ok = handle_describe_decode_or_yaml_action(
key,
app,
&pod,
IoCmdEvent::GetDescribe {
kind: "pod".to_owned(),
value: pod.name.to_owned(),
ns: Some(pod.namespace.to_owned()),
},
)
.await;
if !ok {
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
app.data.selected.pod = Some(pod.name);
app.data.containers.set_items(pod.containers);
}
}
}
ActiveBlock::Containers => {
if key == DEFAULT_KEYBINDING.shell_exec.key {
queue_selected_container_shell_exec(app);
} else if key == DEFAULT_KEYBINDING.previous_logs.key {
handle_previous_logs_for_container(app, RouteId::Home).await;
} else if let Some(c) = handle_block_action(key, &app.data.containers) {
app.data.selected.container = Some(c.name.clone());
app.dispatch_container_logs(c.name, RouteId::Home).await;
}
}
ActiveBlock::Logs => {
if key == DEFAULT_KEYBINDING.log_auto_scroll.key {
if app.log_auto_scroll {
app.data.logs.freeze_follow_position();
}
app.log_auto_scroll = !app.log_auto_scroll;
} else if key == DEFAULT_KEYBINDING.copy_to_clipboard.key {
copy_to_clipboard(app.data.logs.get_plain_text(), app);
}
}
ActiveBlock::Describe | ActiveBlock::Yaml => {
if key == DEFAULT_KEYBINDING.copy_to_clipboard.key {
copy_to_clipboard(app.data.describe_out.get_txt().to_owned(), app);
}
}
ActiveBlock::More => {
if key == DEFAULT_KEYBINDING.submit.key {
let filtered = filter_menu_items(&app.more_resources_menu.items, &app.menu_filter);
let selected_item = app
.more_resources_menu
.state
.selected()
.and_then(|i| filtered.get(i))
.map(|(_, item)| (*item).clone());
if let Some((_title, active_block)) = selected_item {
app.menu_filter.clear();
app.menu_filter_active = false;
app.push_navigation_route(Route {
id: RouteId::Home,
active_block,
});
}
}
}
ActiveBlock::DynamicView => {
if key == DEFAULT_KEYBINDING.submit.key {
let filtered = filter_menu_items(&app.dynamic_resources_menu.items, &app.menu_filter);
let selected_item = app
.dynamic_resources_menu
.state
.selected()
.and_then(|i| filtered.get(i))
.map(|(_, item)| (*item).clone());
if let Some((title, active_block)) = selected_item {
app.menu_filter.clear();
app.menu_filter_active = false;
app.push_navigation_route(Route {
id: RouteId::Home,
active_block,
});
let selected = app.data.dynamic_kinds.iter().find(|it| it.kind == title);
app.data.selected.dynamic_kind = selected.cloned();
if !app.apply_cached_dynamic_resources() {
app.data.dynamic_resources.set_items(vec![]);
}
}
}
}
ActiveBlock::DynamicResource => {
if let Some(dynamic_res) = app.data.selected.dynamic_kind.as_ref() {
if let Some(res) = handle_block_action(key, &app.data.dynamic_resources) {
let describe_action = IoCmdEvent::GetDescribe {
kind: dynamic_res.kind.to_owned(),
value: res.name.to_owned(),
ns: res.namespace.to_owned(),
};
let ok = handle_describe_decode_or_yaml_action(
key,
app,
&res,
describe_action.clone(),
)
.await;
dispatch_describe_on_submit(key, app, ok, describe_action).await;
}
}
}
ActiveBlock::Contexts | ActiveBlock::Utilization | ActiveBlock::Troubleshoot | ActiveBlock::Help => { }
}
)
}
RouteId::Contexts => {
if key == DEFAULT_KEYBINDING.filter.key {
if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
*filter_active = true;
}
} else if let Some(ctx) = handle_block_action(key, &app.data.contexts) {
app.data.selected.context = Some(ctx.name);
app.data.selected.ns = ctx.namespace;
app.refresh();
}
}
RouteId::Utilization => {
if key == DEFAULT_KEYBINDING.filter.key {
if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
*filter_active = true;
}
} else if key == DEFAULT_KEYBINDING.cycle_group_by.key {
if app.utilization_group_by.len() == 1 {
app.utilization_group_by = vec![
GroupBy::Resource,
GroupBy::Node,
GroupBy::Namespace,
GroupBy::Pod,
];
} else {
app.utilization_group_by.pop();
}
app.tick_count = 0; }
}
RouteId::Troubleshoot => {
if key == DEFAULT_KEYBINDING.filter.key {
if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
*filter_active = true;
return;
}
}
match app.get_current_route().active_block {
ActiveBlock::Containers => {
if key == DEFAULT_KEYBINDING.shell_exec.key {
queue_selected_container_shell_exec(app);
} else if key == DEFAULT_KEYBINDING.previous_logs.key {
handle_previous_logs_for_container(app, RouteId::Troubleshoot).await;
} else if let Some(c) = handle_block_action(key, &app.data.containers) {
app.data.selected.container = Some(c.name.clone());
app
.dispatch_container_logs(c.name, RouteId::Troubleshoot)
.await;
}
}
ActiveBlock::Logs => {
if key == DEFAULT_KEYBINDING.log_auto_scroll.key {
if app.log_auto_scroll {
app.data.logs.freeze_follow_position();
}
app.log_auto_scroll = !app.log_auto_scroll;
} else if key == DEFAULT_KEYBINDING.copy_to_clipboard.key {
copy_to_clipboard(app.data.logs.get_plain_text(), app);
}
}
ActiveBlock::Troubleshoot => {
if key == DEFAULT_KEYBINDING.submit.key {
if let Some(finding) = handle_block_action(key, &app.data.troubleshoot_findings) {
if finding.resource_kind == ResourceKind::Pod {
if let Some(idx) = app.data.pods.items.iter().position(|p| {
p.name == finding.resource_name
&& finding
.namespace
.as_deref()
.is_some_and(|ns| p.namespace == ns)
}) {
let pod = app.data.pods.items[idx].clone();
app.data.pods.state.select(Some(idx));
app.data.selected.pod = Some(pod.name);
app.data.containers.set_items(pod.containers);
app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Containers);
}
} else {
let (kind, value, ns) = finding.describe_target();
app.data.describe_out = ScrollableTxt::new();
app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Describe);
app
.dispatch_cmd(IoCmdEvent::GetDescribe {
kind,
value: value.to_owned(),
ns: ns.map(str::to_owned),
})
.await;
}
}
} else if key == DEFAULT_KEYBINDING.describe_resource.key {
if let Some(finding) = handle_block_action(key, &app.data.troubleshoot_findings) {
let (kind, value, ns) = finding.describe_target();
app.data.describe_out = ScrollableTxt::new();
app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Describe);
app
.dispatch_cmd(IoCmdEvent::GetDescribe {
kind,
value: value.to_owned(),
ns: ns.map(str::to_owned),
})
.await;
}
} else if key == DEFAULT_KEYBINDING.resource_yaml.key {
if let Some(finding) = handle_block_action(key, &app.data.troubleshoot_findings) {
let yaml = match finding.resource_kind {
ResourceKind::Pod => app
.data
.pods
.items
.iter()
.find(|p| {
p.name == finding.resource_name
&& finding
.namespace
.as_deref()
.is_some_and(|ns| p.namespace == ns)
})
.map(|p| p.resource_to_yaml())
.unwrap_or_default(),
ResourceKind::Pvc => app
.data
.persistent_volume_claims
.items
.iter()
.find(|pvc| {
pvc.name == finding.resource_name
&& finding
.namespace
.as_deref()
.is_some_and(|ns| pvc.namespace == ns)
})
.map(|pvc| pvc.resource_to_yaml())
.unwrap_or_default(),
ResourceKind::ReplicaSet => app
.data
.replica_sets
.items
.iter()
.find(|rs| {
rs.name == finding.resource_name
&& finding
.namespace
.as_deref()
.is_some_and(|ns| rs.namespace == ns)
})
.map(|rs| rs.resource_to_yaml())
.unwrap_or_default(),
};
app.data.describe_out = ScrollableTxt::with_string(yaml);
app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Yaml);
}
}
}
_ => {}
}
}
RouteId::HelpMenu => {
if key == DEFAULT_KEYBINDING.filter.key {
if let Some((_, filter_active, _)) = app.current_resource_filter_mut() {
*filter_active = true;
}
}
}
}
if key == DEFAULT_KEYBINDING.submit.key {
app.tick_count = 0;
}
}
async fn handle_previous_logs_for_container(app: &mut App, route_id: RouteId) {
let Some(container) = app.data.containers.get_selected_item_copy() else {
return;
};
app.data.selected.container = Some(container.name.clone());
app.dispatch_previous_logs(container.name, route_id).await;
}
async fn handle_previous_logs_for_pod(app: &mut App, route_id: RouteId) {
let Some(pod) = app.data.pods.get_selected_item_copy() else {
return;
};
let Some(container) = resolve_shell_container(app, &pod) else {
app.handle_error(anyhow!(
"Open the containers view to pick a container for previous logs on pod {}",
pod.name
));
return;
};
app.data.selected.pod = Some(pod.name.clone());
app.data.selected.container = Some(container.name.clone());
app.dispatch_previous_logs(container.name, route_id).await;
}
fn queue_selected_resource_edit(app: &mut App) {
let block = app.get_current_route().active_block;
let Some((name, namespace)) = selected_target(app, block) else {
app.handle_error(anyhow!("No resource selected to edit"));
return;
};
let kind = resource_kind_label(app, block);
app.queue_edit(PendingEdit {
namespace,
kind,
name,
});
}
fn port_forward_kind(block: ActiveBlock) -> Option<&'static str> {
match block {
ActiveBlock::Pods => Some("pods"),
ActiveBlock::Services => Some("services"),
_ => None,
}
}
fn open_port_forward_input(app: &mut App) {
let block = app.get_current_route().active_block;
let Some(kind) = port_forward_kind(block) else {
return;
};
let Some((name, namespace)) = selected_target(app, block) else {
app.handle_error(anyhow!("No resource selected to port-forward"));
return;
};
let Some(namespace) = namespace else {
return;
};
app.open_input_modal(InputModal {
title: "Port-forward".to_owned(),
prompt: format!("Ports for {} '{}' (local:remote or port):", kind, name),
buffer: String::new(),
error: None,
action: InputAction::PortForward {
kind: kind.to_owned(),
namespace,
name,
},
});
}
async fn handle_port_forwards_key(key: Key, app: &mut App) {
match key {
_ if key == DEFAULT_KEYBINDING.esc.key => app.close_port_forwards(),
_ if key == DEFAULT_KEYBINDING.up.key
|| key == DEFAULT_KEYBINDING.up.alt.unwrap()
|| key == Key::Up =>
{
let len = app.port_forwards.len();
move_list_selection(&mut app.port_forwards_state, len, -1);
}
_ if key == DEFAULT_KEYBINDING.down.key
|| key == DEFAULT_KEYBINDING.down.alt.unwrap()
|| key == Key::Down =>
{
let len = app.port_forwards.len();
move_list_selection(&mut app.port_forwards_state, len, 1);
}
_ if key == DEFAULT_KEYBINDING.submit.key
|| key == DEFAULT_KEYBINDING.delete_resource.key
|| key == Key::Char('d') =>
{
if let Some(id) = app.selected_port_forward_id() {
app
.dispatch_stream(IoStreamEvent::StopPortForward { id })
.await;
}
}
_ => {}
}
}
fn move_list_selection(state: &mut ratatui::widgets::ListState, len: usize, delta: isize) {
if len == 0 {
state.select(None);
return;
}
let current = state.selected().unwrap_or(0) as isize;
let next = (current + delta).clamp(0, len as isize - 1) as usize;
state.select(Some(next));
}
fn queue_selected_container_shell_exec(app: &mut App) {
let Some(pod) = app.data.pods.get_selected_item_copy() else {
app.handle_error(anyhow!("No pod selected for shell exec"));
return;
};
let Some(container) = resolve_shell_container(app, &pod) else {
app.handle_error(anyhow!(
"No container selected for shell exec on pod {}",
pod.name
));
return;
};
app.queue_shell_exec(PendingShellExec {
namespace: pod.namespace,
pod: pod.name,
container: container.name,
});
}
fn resolve_shell_container(
app: &App,
pod: &crate::app::pods::KubePod,
) -> Option<crate::app::pods::KubeContainer> {
if let Some(container) = app.data.containers.get_selected_item_copy() {
return Some(container);
}
if let Some(selected_name) = app.data.selected.container.as_ref() {
if let Some(container) = pod
.containers
.iter()
.find(|container| container.name == *selected_name)
{
return Some(container.clone());
}
}
let mut non_init = pod.containers.iter().filter(|container| !container.init);
let first_non_init = non_init.next();
if let Some(container) = first_non_init {
if non_init.next().is_none() {
return Some(container.clone());
}
}
if pod.containers.len() == 1 {
return pod.containers.first().cloned();
}
None
}
fn handle_block_action<T: Clone>(key: Key, item: &StatefulTable<T>) -> Option<T> {
match key {
_ if key == DEFAULT_KEYBINDING.submit.key
|| key == DEFAULT_KEYBINDING.describe_resource.key
|| key == DEFAULT_KEYBINDING.resource_yaml.key
|| key == DEFAULT_KEYBINDING.decode_secret.key =>
{
item.get_selected_item_copy()
}
_ => None,
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum ScrollEvent {
Absolute(isize),
Relative(isize),
End,
}
impl ScrollEvent {
pub fn down() -> Self {
ScrollEvent::Relative(1)
}
pub fn up() -> Self {
ScrollEvent::Relative(-1)
}
}
async fn handle_block_scroll(app: &mut App, event: ScrollEvent, is_mouse: bool) {
handle_resource_scroll!(app.get_current_route().active_block, app, event, is_mouse,
[
(ActiveBlock::Namespaces, namespaces),
(ActiveBlock::Pods, pods),
(ActiveBlock::Containers, containers),
(ActiveBlock::Services, services),
(ActiveBlock::Nodes, nodes),
(ActiveBlock::ConfigMaps, config_maps),
(ActiveBlock::StatefulSets, stateful_sets),
(ActiveBlock::ReplicaSets, replica_sets),
(ActiveBlock::Deployments, deployments),
(ActiveBlock::Jobs, jobs),
(ActiveBlock::DaemonSets, daemon_sets),
(ActiveBlock::CronJobs, cronjobs),
(ActiveBlock::Secrets, secrets),
(ActiveBlock::ReplicationControllers, replication_controllers),
(ActiveBlock::StorageClasses, storage_classes),
(ActiveBlock::Roles, roles),
(ActiveBlock::RoleBindings, role_bindings),
(ActiveBlock::ClusterRoles, cluster_roles),
(ActiveBlock::ClusterRoleBindings, cluster_role_bindings),
(ActiveBlock::PersistentVolumeClaims, persistent_volume_claims),
(ActiveBlock::PersistentVolumes, persistent_volumes),
(ActiveBlock::Ingresses, ingress),
(ActiveBlock::ServiceAccounts, service_accounts),
(ActiveBlock::Events, events),
(ActiveBlock::NetworkPolicies, network_policies),
(ActiveBlock::DynamicResource, dynamic_resources),
],
extra: {
ActiveBlock::Contexts => app.data.contexts.handle_scroll(event),
ActiveBlock::Utilization => app.data.metrics.handle_scroll(event),
ActiveBlock::Troubleshoot => app.data.troubleshoot_findings.handle_scroll(event),
ActiveBlock::Help => {
app.help_scroll = match event {
ScrollEvent::Absolute(n) => n.clamp(0, u16::MAX as isize) as u16,
ScrollEvent::Relative(delta) if delta < 0 => {
app.help_scroll.saturating_sub(delta.unsigned_abs() as u16)
}
ScrollEvent::Relative(delta) => app.help_scroll.saturating_add(delta as u16),
ScrollEvent::End => u16::MAX,
};
}
ActiveBlock::More => {
let filtered_len = filter_menu_items(&app.more_resources_menu.items, &app.menu_filter).len();
handle_menu_scroll(&mut app.more_resources_menu, event, filtered_len);
}
ActiveBlock::DynamicView => {
let filtered_len = filter_menu_items(&app.dynamic_resources_menu.items, &app.menu_filter).len();
handle_menu_scroll(&mut app.dynamic_resources_menu, event, filtered_len);
}
ActiveBlock::Logs => {
if app.log_auto_scroll {
app.data.logs.freeze_follow_position();
app.log_auto_scroll = false;
}
app.data.logs.handle_scroll(inverse_dir(event, is_mouse));
}
ActiveBlock::Describe | ActiveBlock::Yaml => app
.data
.describe_out
.handle_scroll(inverse_dir(event, is_mouse)),
}
)
}
fn handle_menu_scroll(
menu: &mut StatefulList<(String, ActiveBlock)>,
event: ScrollEvent,
filtered_len: usize,
) {
if filtered_len == 0 {
return;
}
let newpos = match event {
ScrollEvent::Absolute(newpos) => newpos,
ScrollEvent::Relative(delta) => menu.current_pos().unwrap_or(0) as isize + delta,
ScrollEvent::End => filtered_len as isize - 1,
}
.rem_euclid(filtered_len as isize);
menu.state.select(Some(newpos as usize));
}
fn copy_to_clipboard(content: String, app: &mut App) {
use std::thread;
use anyhow::anyhow;
use copypasta::{ClipboardContext, ClipboardProvider};
match ClipboardContext::new() {
Ok(mut ctx) => match ctx.set_contents(content) {
Ok(_) => thread::sleep(std::time::Duration::from_millis(100)),
Err(_) => app.handle_error(anyhow!("Unable to set clipboard contents".to_string())),
},
Err(err) => {
app.handle_error(anyhow!("Unable to obtain clipboard: {}", err));
}
};
}
fn dump_error_history(app: &mut App, output_dir: Option<&Path>) {
match write_error_history_file(&app.error_history, output_dir) {
Ok(path) => app.set_status_message(format!("Saved recent errors to {}", path.display())),
Err(error) => app.handle_error(anyhow::anyhow!("Unable to write error log: {}", error)),
}
}
fn write_error_history_file(
history: &std::collections::VecDeque<crate::app::ErrorRecord>,
output_dir: Option<&Path>,
) -> std::io::Result<PathBuf> {
let dir = match output_dir {
Some(path) => path.to_path_buf(),
None => std::env::current_dir()?,
};
let path = dir.join(format!(
"kdash-errors-{}.log",
chrono::Local::now().format("%Y%m%d%H%M%S")
));
fs::write(&path, format_error_history(history))?;
Ok(path)
}
fn format_error_history(history: &std::collections::VecDeque<crate::app::ErrorRecord>) -> String {
if history.is_empty() {
"No errors recorded\n".to_owned()
} else {
let mut rendered = history
.iter()
.map(|record| format!("[{}] {}", record.timestamp, record.message))
.collect::<Vec<_>>()
.join("\n");
rendered.push('\n');
rendered
}
}
fn inverse_dir(event: ScrollEvent, is_mouse: bool) -> ScrollEvent {
match event {
ScrollEvent::Relative(delta) if is_mouse => ScrollEvent::Relative(-delta),
other => other,
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
time::{SystemTime, UNIX_EPOCH},
};
use crossterm::event::KeyCode;
use k8s_openapi::ByteString;
use kube::{
api::ObjectMeta,
core::{ApiResource, DynamicObject},
discovery::Scope,
};
use tokio::sync::mpsc;
use super::*;
use crate::app::{
contexts::KubeContext,
dynamic::{dynamic_cache_key, KubeDynamicKind, KubeDynamicResource},
pods::{KubeContainer, KubePod},
PendingShellExec,
};
#[test]
fn test_inverse_dir() {
assert_eq!(inverse_dir(ScrollEvent::down(), false), ScrollEvent::down());
assert_eq!(inverse_dir(ScrollEvent::down(), true), ScrollEvent::up());
}
fn temp_test_dir(name: &str) -> std::path::PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("kdash-{name}-{suffix}"));
fs::create_dir_all(&path).expect("temp test dir should be created");
path
}
#[test]
fn test_write_error_history_file_writes_recent_errors() {
let dir = temp_test_dir("error-dump");
let mut app = App::default();
app.record_error("first error".into());
app.record_error("second error".into());
let path = write_error_history_file(&app.error_history, Some(&dir)).unwrap();
let contents = fs::read_to_string(path).unwrap();
assert!(contents.contains("first error"));
assert!(contents.contains("second error"));
}
#[test]
fn test_write_error_history_file_writes_empty_message_when_no_errors() {
let dir = temp_test_dir("empty-error-dump");
let app = App::default();
let path = write_error_history_file(&app.error_history, Some(&dir)).unwrap();
let contents = fs::read_to_string(path).unwrap();
assert_eq!(contents, "No errors recorded\n");
}
#[tokio::test]
async fn test_dump_error_key_creates_file_and_sets_status_message() {
let dir = temp_test_dir("dump-key");
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let mut app = App::default();
app.record_error("boom".into());
let key_evt = KeyEvent {
code: KeyCode::Char('D'),
modifiers: crossterm::event::KeyModifiers::SHIFT,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
};
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
let created_files = fs::read_dir(&dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect::<Vec<_>>();
std::env::set_current_dir(original_dir).unwrap();
assert!(created_files
.iter()
.any(|name| name.starts_with("kdash-errors-") && name.ends_with(".log")));
assert!(app.api_error.is_empty());
assert!(app.status_message.text().contains("Saved recent errors to"));
}
#[tokio::test]
async fn test_shell_exec_key_in_containers_queues_request() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let mut container = KubeContainer::default();
container.name = "app".into();
app.data.containers.set_items(vec![container]);
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(
app.pending_shell_exec(),
Some(&PendingShellExec {
namespace: "team-a".into(),
pod: "pod-1".into(),
container: "app".into(),
})
);
assert!(app.api_error.is_empty());
}
#[tokio::test]
async fn test_shell_exec_key_in_containers_requires_selected_container() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.pending_shell_exec(), None);
assert_eq!(
app.api_error,
"No container selected for shell exec on pod pod-1"
);
}
#[tokio::test]
async fn test_shell_exec_key_in_containers_uses_selected_container_fallback() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
let mut sidecar = KubeContainer::default();
sidecar.name = "sidecar".into();
sidecar.pod_name = "pod-1".into();
let mut app_container = KubeContainer::default();
app_container.name = "app".into();
app_container.pod_name = "pod-1".into();
pod.containers = vec![app_container.clone(), sidecar];
app.data.pods.set_items(vec![pod]);
app.data.selected.container = Some("app".into());
app.data.containers.items.clear();
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(
app.pending_shell_exec(),
Some(&PendingShellExec {
namespace: "team-a".into(),
pod: "pod-1".into(),
container: "app".into(),
})
);
assert!(app.api_error.is_empty());
}
#[tokio::test]
async fn test_shell_exec_key_in_containers_uses_single_non_init_container_fallback() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
let mut app_container = KubeContainer::default();
app_container.name = "app".into();
app_container.pod_name = "pod-1".into();
let mut init_container = KubeContainer::default();
init_container.name = "init-db".into();
init_container.pod_name = "pod-1".into();
init_container.init = true;
pod.containers = vec![app_container, init_container];
app.data.pods.set_items(vec![pod]);
app.data.containers.items.clear();
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(
app.pending_shell_exec(),
Some(&PendingShellExec {
namespace: "team-a".into(),
pod: "pod-1".into(),
container: "app".into(),
})
);
assert!(app.api_error.is_empty());
}
#[tokio::test]
async fn test_shell_exec_key_in_containers_requires_selection_when_multiple_main_containers() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
let mut app_container = KubeContainer::default();
app_container.name = "app".into();
app_container.pod_name = "pod-1".into();
let mut sidecar = KubeContainer::default();
sidecar.name = "sidecar".into();
sidecar.pod_name = "pod-1".into();
pod.containers = vec![app_container, sidecar];
app.data.pods.set_items(vec![pod]);
app.data.containers.items.clear();
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.pending_shell_exec(), None);
assert_eq!(
app.api_error,
"No container selected for shell exec on pod pod-1"
);
}
#[tokio::test]
async fn test_shell_exec_key_in_containers_requires_selected_pod() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut container = KubeContainer::default();
container.name = "app".into();
app.data.containers.set_items(vec![container]);
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.pending_shell_exec(), None);
assert_eq!(app.api_error, "No pod selected for shell exec");
}
#[tokio::test]
async fn test_shell_exec_key_does_not_replace_log_auto_scroll_in_logs() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
assert!(app.log_auto_scroll);
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.log_auto_scroll);
assert_eq!(app.pending_shell_exec(), None);
}
#[tokio::test]
async fn test_shell_exec_key_in_troubleshoot_containers_queues_request() {
let mut app = App::default();
app.route_troubleshoot();
app.push_navigation_stack(RouteId::Troubleshoot, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let mut container = KubeContainer::default();
container.name = "app".into();
app.data.containers.set_items(vec![container]);
let key_evt = KeyEvent::from(KeyCode::Char('s'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(
app.pending_shell_exec(),
Some(&PendingShellExec {
namespace: "team-a".into(),
pod: "pod-1".into(),
container: "app".into(),
})
);
assert!(app.api_error.is_empty());
}
#[tokio::test]
async fn test_resource_filter_key_flow() {
let mut app = App::default();
app.route_home();
assert!(!app.data.pods.filter_active);
assert!(app.data.pods.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Char('/'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.pods.filter_active);
for c in ['w', 'e', 'b'] {
let key_evt = KeyEvent::from(KeyCode::Char(c));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
}
assert_eq!(app.data.pods.filter, "web");
let key_evt = KeyEvent::from(KeyCode::Backspace);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.data.pods.filter, "we");
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.pods.filter_active);
assert!(app.data.pods.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.data.pods.filter_active);
}
#[tokio::test]
async fn test_containers_filter_key_flow() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
assert!(!app.data.containers.filter_active);
assert!(app.data.containers.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Char('/'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.containers.filter_active);
for c in ['n', 'g', 'i', 'n', 'x'] {
let key_evt = KeyEvent::from(KeyCode::Char(c));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
}
assert_eq!(app.data.containers.filter, "nginx");
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.containers.filter_active);
assert!(app.data.containers.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.data.containers.filter_active);
}
#[tokio::test]
async fn test_action_menu_opens_and_closes_on_pods() {
use crate::app::actions::ResourceAction;
let mut app = App::default();
app.route_home();
let m = KeyEvent::from(KeyCode::Char('m'));
handle_key_events(Key::from(m), m, &mut app).await;
let menu = app.action_menu.as_ref().expect("action menu should open");
assert_eq!(
menu.items,
vec![
ResourceAction::Describe,
ResourceAction::Yaml,
ResourceAction::Edit,
ResourceAction::Logs,
ResourceAction::PreviousLogs,
ResourceAction::PortForward,
ResourceAction::Delete
]
);
let esc = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(esc), esc, &mut app).await;
assert!(app.action_menu.is_none());
}
#[tokio::test]
async fn test_action_menu_not_opened_on_blocks_without_actions() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
let m = KeyEvent::from(KeyCode::Char('m'));
handle_key_events(Key::from(m), m, &mut app).await;
assert!(app.action_menu.is_none());
}
#[tokio::test]
async fn test_action_menu_swallows_unrelated_keys_while_open() {
let mut app = App::default();
app.route_home();
let m = KeyEvent::from(KeyCode::Char('m'));
handle_key_events(Key::from(m), m, &mut app).await;
assert!(app.action_menu.is_some());
let block_before = app.get_current_route().active_block;
let right = KeyEvent::from(KeyCode::Right);
handle_key_events(Key::from(right), right, &mut app).await;
assert!(app.action_menu.is_some());
assert_eq!(app.get_current_route().active_block, block_before);
}
#[tokio::test]
async fn test_action_menu_shell_replays_shell_exec_in_containers() {
use crate::app::actions::ResourceAction;
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let mut container = KubeContainer::default();
container.name = "app".into();
app.data.containers.set_items(vec![container]);
let m = KeyEvent::from(KeyCode::Char('m'));
handle_key_events(Key::from(m), m, &mut app).await;
assert_eq!(
app.action_menu.as_ref().unwrap().items,
vec![
ResourceAction::Logs,
ResourceAction::PreviousLogs,
ResourceAction::Shell
]
);
for _ in 0..2 {
let down = KeyEvent::from(KeyCode::Down);
handle_key_events(Key::from(down), down, &mut app).await;
}
let enter = KeyEvent::from(KeyCode::Enter);
handle_key_events(Key::from(enter), enter, &mut app).await;
assert!(app.action_menu.is_none());
assert_eq!(
app.pending_shell_exec(),
Some(&PendingShellExec {
namespace: "team-a".into(),
pod: "pod-1".into(),
container: "app".into(),
})
);
}
fn ctrl_key(c: char) -> KeyEvent {
KeyEvent {
code: KeyCode::Char(c),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
fn make_node(name: &str, unschedulable: bool) -> crate::app::nodes::KubeNode {
use k8s_openapi::{
api::core::v1::{Node, NodeSpec, Pod},
apimachinery::pkg::apis::meta::v1::{ListMeta, ObjectMeta},
};
use kube::{api::ObjectList, core::TypeMeta};
let node = Node {
metadata: ObjectMeta {
name: Some(name.into()),
..Default::default()
},
spec: Some(NodeSpec {
unschedulable: Some(unschedulable),
..Default::default()
}),
..Default::default()
};
let pods = ObjectList::<Pod> {
types: TypeMeta {
api_version: "v1".into(),
kind: "List".into(),
},
metadata: ListMeta::default(),
items: vec![],
};
let seed = tokio::sync::Mutex::new(App::default());
let mut guard = seed.try_lock().expect("uncontended lock");
crate::app::nodes::KubeNode::from_api_with_pods(&node, &pods, &mut guard)
}
fn shift_char(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), crossterm::event::KeyModifiers::SHIFT)
}
async fn open_menu_and_select(app: &mut App, steps: usize) {
let mut codes = Vec::with_capacity(steps + 2);
codes.push(KeyCode::Char('m'));
for _ in 0..steps {
codes.push(KeyCode::Down);
}
codes.push(KeyCode::Enter);
send_keys(app, &codes).await;
}
async fn send_keys(app: &mut App, codes: &[KeyCode]) {
for &code in codes {
let evt = KeyEvent::from(code);
handle_key_events(Key::from(evt), evt, app).await;
}
}
#[tokio::test]
async fn test_menu_cordon_on_schedulable_node_confirms_cordon() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Nodes);
app.data.nodes.set_items(vec![make_node("n1", false)]);
open_menu_and_select(&mut app, 3).await;
let modal = app
.modal
.as_ref()
.expect("cordon should open a confirm modal");
assert!(modal.prompt.contains("Cordon node 'n1'"));
assert_eq!(
modal.on_confirm,
IoEvent::PatchResource {
block: ActiveBlock::Nodes,
name: "n1".into(),
namespace: None,
patch: ResourcePatch::SetUnschedulable(true),
}
);
}
#[tokio::test]
async fn test_menu_cordon_on_cordoned_node_confirms_uncordon() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Nodes);
app.data.nodes.set_items(vec![make_node("n1", true)]);
open_menu_and_select(&mut app, 3).await;
let modal = app
.modal
.as_ref()
.expect("uncordon should open a confirm modal");
assert!(modal.prompt.contains("Uncordon node 'n1'"));
assert_eq!(
modal.on_confirm,
IoEvent::PatchResource {
block: ActiveBlock::Nodes,
name: "n1".into(),
namespace: None,
patch: ResourcePatch::SetUnschedulable(false),
}
);
}
#[tokio::test]
async fn test_delete_key_opens_confirm_modal_with_correct_event() {
let mut app = App::default();
app.route_home();
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let ctrl_d = ctrl_key('d');
handle_key_events(Key::from(ctrl_d), ctrl_d, &mut app).await;
let modal = app
.modal
.as_ref()
.expect("delete should open a confirm modal");
assert_eq!(
modal.on_confirm,
IoEvent::DeleteResource {
block: ActiveBlock::Pods,
name: "pod-1".into(),
namespace: Some("team-a".into()),
}
);
assert!(modal.prompt.contains("pod-1"));
assert!(modal.prompt.contains("team-a"));
}
#[tokio::test]
async fn test_delete_key_is_noop_without_selected_row() {
let mut app = App::default();
app.route_home();
let ctrl_d = ctrl_key('d');
handle_key_events(Key::from(ctrl_d), ctrl_d, &mut app).await;
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_modal_cancel_closes_without_dispatch() {
let mut app = App::default();
app.route_home();
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let ctrl_d = ctrl_key('d');
handle_key_events(Key::from(ctrl_d), ctrl_d, &mut app).await;
assert!(app.modal.is_some());
let n = KeyEvent::from(KeyCode::Char('n'));
handle_key_events(Key::from(n), n, &mut app).await;
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_modal_confirm_clears_modal() {
let mut app = App::default();
app.route_home();
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let ctrl_d = ctrl_key('d');
handle_key_events(Key::from(ctrl_d), ctrl_d, &mut app).await;
assert!(app.modal.is_some());
let y = KeyEvent::from(KeyCode::Char('y'));
handle_key_events(Key::from(y), y, &mut app).await;
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_action_menu_delete_entry_opens_confirm_modal() {
let mut app = App::default();
app.route_home();
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let m = KeyEvent::from(KeyCode::Char('m'));
handle_key_events(Key::from(m), m, &mut app).await;
for _ in 0..6 {
let down = KeyEvent::from(KeyCode::Down);
handle_key_events(Key::from(down), down, &mut app).await;
}
let enter = KeyEvent::from(KeyCode::Enter);
handle_key_events(Key::from(enter), enter, &mut app).await;
assert!(app.action_menu.is_none());
let modal = app
.modal
.as_ref()
.expect("menu delete should open confirm modal");
assert_eq!(
modal.on_confirm,
IoEvent::DeleteResource {
block: ActiveBlock::Pods,
name: "pod-1".into(),
namespace: Some("team-a".into()),
}
);
}
#[tokio::test]
async fn test_edit_key_queues_pending_edit_for_namespaced_resource() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Deployments);
app
.data
.deployments
.set_items(vec![deployment_with_replicas("web", "team-a", Some(2))]);
let e = KeyEvent::from(KeyCode::Char('e'));
handle_key_events(Key::from(e), e, &mut app).await;
assert_eq!(
app.pending_edit(),
Some(&PendingEdit {
namespace: Some("team-a".into()),
kind: "deployment".into(),
name: "web".into(),
})
);
}
#[tokio::test]
async fn test_edit_for_cluster_scoped_resource_has_no_namespace() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Nodes);
app.data.nodes.set_items(vec![make_node("n1", false)]);
let e = KeyEvent::from(KeyCode::Char('e'));
handle_key_events(Key::from(e), e, &mut app).await;
assert_eq!(
app.pending_edit(),
Some(&PendingEdit {
namespace: None,
kind: "node".into(),
name: "n1".into(),
})
);
}
#[tokio::test]
async fn test_action_menu_edit_queues_pending_edit() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
let mut pod = KubePod::default();
pod.name = "pod-1".into();
pod.namespace = "team-a".into();
app.data.pods.set_items(vec![pod]);
open_menu_and_select(&mut app, 2).await;
assert_eq!(
app.pending_edit(),
Some(&PendingEdit {
namespace: Some("team-a".into()),
kind: "pod".into(),
name: "pod-1".into(),
})
);
}
#[tokio::test]
async fn test_log_view_toggles_timestamps_and_wrap() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
assert!(!app.log_timestamps);
assert!(app.log_wrap);
let t = KeyEvent::from(KeyCode::Char('t'));
handle_key_events(Key::from(t), t, &mut app).await;
assert!(app.log_timestamps, "'t' toggles timestamps in the log view");
let w = KeyEvent::from(KeyCode::Char('w'));
handle_key_events(Key::from(w), w, &mut app).await;
assert!(!app.log_wrap, "'w' toggles wrap in the log view");
}
#[tokio::test]
async fn test_t_outside_log_view_does_not_toggle_timestamps() {
let mut app = App::default();
app.route_home();
assert_ne!(app.get_current_route().active_block, ActiveBlock::Logs);
let before = app.log_timestamps;
let t = KeyEvent::from(KeyCode::Char('t'));
handle_key_events(Key::from(t), t, &mut app).await;
assert_eq!(
app.log_timestamps, before,
"'t' cycles theme (not log timestamps) outside the log view"
);
}
#[tokio::test]
async fn test_restart_key_opens_confirm_for_deployment() {
use k8s_openapi::api::apps::v1::Deployment;
use crate::app::deployments::KubeDeployment;
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Deployments);
let mut dep = KubeDeployment::from(Deployment::default());
dep.name = "web".into();
dep.namespace = "team-a".into();
app.data.deployments.set_items(vec![dep]);
let r = KeyEvent::from(KeyCode::Char('r'));
handle_key_events(Key::from(r), r, &mut app).await;
let modal = app
.modal
.as_ref()
.expect("restart should open a confirm modal");
assert_eq!(
modal.on_confirm,
IoEvent::PatchResource {
block: ActiveBlock::Deployments,
name: "web".into(),
namespace: Some("team-a".into()),
patch: ResourcePatch::RolloutRestart,
}
);
}
fn deployment_with_replicas(
name: &str,
ns: &str,
replicas: Option<i32>,
) -> crate::app::deployments::KubeDeployment {
use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec};
use crate::app::deployments::KubeDeployment;
let dep = Deployment {
spec: replicas.map(|r| DeploymentSpec {
replicas: Some(r),
..Default::default()
}),
..Default::default()
};
let mut dep = KubeDeployment::from(dep);
dep.name = name.into();
dep.namespace = ns.into();
dep
}
#[tokio::test]
async fn test_menu_scale_deployment_opens_input_prefilled() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Deployments);
app
.data
.deployments
.set_items(vec![deployment_with_replicas("web", "team-a", Some(2))]);
open_menu_and_select(&mut app, 5).await;
let input = app
.input_modal
.as_ref()
.expect("scale should open an input modal");
assert_eq!(input.buffer, "2");
assert_eq!(
input.action,
InputAction::Scale {
block: ActiveBlock::Deployments,
name: "web".into(),
namespace: Some("team-a".into()),
kind: "deployment".into(),
}
);
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_scale_input_submit_chains_to_confirm_modal() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Deployments);
app
.data
.deployments
.set_items(vec![deployment_with_replicas("web", "team-a", Some(2))]);
send_keys(
&mut app,
&[
KeyCode::Char('m'),
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Enter,
KeyCode::Backspace,
KeyCode::Char('5'),
KeyCode::Enter,
],
)
.await;
assert!(app.input_modal.is_none());
let modal = app
.modal
.as_ref()
.expect("valid scale submit should chain into a confirm modal");
assert!(modal.prompt.contains("to 5 replica(s)"));
assert_eq!(
modal.on_confirm,
IoEvent::PatchResource {
block: ActiveBlock::Deployments,
name: "web".into(),
namespace: Some("team-a".into()),
patch: ResourcePatch::SetReplicas(5),
}
);
}
#[tokio::test]
async fn test_scale_input_invalid_keeps_modal_open_with_error() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Deployments);
app
.data
.deployments
.set_items(vec![deployment_with_replicas("web", "team-a", Some(2))]);
send_keys(
&mut app,
&[
KeyCode::Char('m'),
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Enter,
KeyCode::Char('x'),
KeyCode::Enter,
],
)
.await;
let input = app
.input_modal
.as_ref()
.expect("invalid input keeps the modal open");
assert!(input.error.is_some());
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_scale_input_esc_cancels() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Deployments);
app
.data
.deployments
.set_items(vec![deployment_with_replicas("web", "team-a", Some(2))]);
send_keys(
&mut app,
&[
KeyCode::Char('m'),
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Down,
KeyCode::Enter,
KeyCode::Esc,
],
)
.await;
assert!(app.input_modal.is_none());
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_port_forward_key_opens_input_for_pod() {
let mut app = App::default();
app.route_home();
let mut pod = KubePod::default();
pod.name = "web".into();
pod.namespace = "team-a".into();
app.data.pods.set_items(vec![pod]);
let f = KeyEvent::from(KeyCode::Char('f'));
handle_key_events(Key::from(f), f, &mut app).await;
let input = app
.input_modal
.as_ref()
.expect("port-forward should open an input modal");
assert_eq!(
input.action,
InputAction::PortForward {
kind: "pods".into(),
namespace: "team-a".into(),
name: "web".into(),
}
);
}
#[tokio::test]
async fn test_port_forward_input_submit_closes_modal() {
let mut app = App::default();
app.route_home();
let mut pod = KubePod::default();
pod.name = "web".into();
pod.namespace = "team-a".into();
app.data.pods.set_items(vec![pod]);
send_keys(
&mut app,
&[
KeyCode::Char('f'),
KeyCode::Char('8'),
KeyCode::Char('0'),
KeyCode::Char('8'),
KeyCode::Char('0'),
KeyCode::Char(':'),
KeyCode::Char('8'),
KeyCode::Char('0'),
KeyCode::Enter,
],
)
.await;
assert!(app.input_modal.is_none());
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_port_forwards_list_key_empty_is_noop() {
let mut app = App::default();
app.route_home();
let f = shift_char('F');
handle_key_events(Key::from(f), f, &mut app).await;
assert!(!app.show_port_forwards);
assert!(!app.status_message.is_empty());
}
#[tokio::test]
async fn test_port_forwards_overlay_navigates_and_swallows_keys() {
use crate::app::port_forward::{PortForward, PortForwardStatus};
let mut app = App::default();
app.route_home();
for (id, port) in [(0u64, 8080u16), (1, 9090)] {
app.port_forwards.push(PortForward {
id,
kind: "pods".into(),
namespace: "default".into(),
name: format!("web-{id}"),
local_port: port,
remote_port: 80,
status: PortForwardStatus::Active,
child: None,
});
}
let upper_f = shift_char('F');
handle_key_events(Key::from(upper_f), upper_f, &mut app).await;
assert!(app.show_port_forwards);
assert_eq!(app.selected_port_forward_id(), Some(0));
let block_before = app.get_current_route().active_block;
send_keys(&mut app, &[KeyCode::Down, KeyCode::Char('2')]).await;
assert_eq!(app.selected_port_forward_id(), Some(1));
assert_eq!(app.get_current_route().active_block, block_before);
let esc = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(esc), esc, &mut app).await;
assert!(!app.show_port_forwards);
}
#[tokio::test]
async fn test_restart_key_is_noop_on_non_workload_block() {
let mut app = App::default();
app.route_home();
let mut pod = KubePod::default();
pod.name = "p".into();
pod.namespace = "n".into();
app.data.pods.set_items(vec![pod]);
let r = KeyEvent::from(KeyCode::Char('r'));
handle_key_events(Key::from(r), r, &mut app).await;
assert!(app.modal.is_none());
}
#[tokio::test]
async fn test_menu_suspend_cronjob_confirms_suspend() {
use k8s_openapi::api::batch::v1::CronJob;
use crate::app::cronjobs::KubeCronJob;
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::CronJobs);
let mut cronjob = KubeCronJob::from(CronJob::default());
cronjob.name = "backup".into();
cronjob.namespace = "default".into();
cronjob.suspend = false;
app.data.cronjobs.set_items(vec![cronjob]);
open_menu_and_select(&mut app, 4).await;
let modal = app
.modal
.as_ref()
.expect("suspend should open a confirm modal");
assert_eq!(
modal.on_confirm,
IoEvent::PatchResource {
block: ActiveBlock::CronJobs,
name: "backup".into(),
namespace: Some("default".into()),
patch: ResourcePatch::SetSuspend(true),
}
);
}
#[tokio::test]
async fn test_menu_trigger_cronjob_confirms_trigger() {
use k8s_openapi::api::batch::v1::CronJob;
use crate::app::cronjobs::KubeCronJob;
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::CronJobs);
let mut cronjob = KubeCronJob::from(CronJob::default());
cronjob.name = "backup".into();
cronjob.namespace = "default".into();
app.data.cronjobs.set_items(vec![cronjob]);
open_menu_and_select(&mut app, 5).await;
let modal = app
.modal
.as_ref()
.expect("trigger should open a confirm modal");
assert_eq!(
modal.on_confirm,
IoEvent::TriggerCronJob {
name: "backup".into(),
namespace: "default".into(),
}
);
}
#[tokio::test]
async fn test_previous_logs_key_in_containers_opens_previous_log_view() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let mut container = KubeContainer::default();
container.name = "app".into();
app.data.containers.set_items(vec![container]);
let p = KeyEvent::from(KeyCode::Char('p'));
handle_key_events(Key::from(p), p, &mut app).await;
assert_eq!(app.get_current_route().active_block, ActiveBlock::Logs);
assert!(app.log_previous);
assert_eq!(app.data.selected.container.as_deref(), Some("app"));
assert_eq!(app.data.logs.id, "app");
}
#[tokio::test]
async fn test_leaving_previous_logs_resets_flag() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
let mut pod = KubePod::default();
pod.namespace = "team-a".into();
pod.name = "pod-1".into();
app.data.pods.set_items(vec![pod]);
let mut container = KubeContainer::default();
container.name = "app".into();
app.data.containers.set_items(vec![container]);
let p = KeyEvent::from(KeyCode::Char('p'));
handle_key_events(Key::from(p), p, &mut app).await;
assert!(app.log_previous);
let esc = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(esc), esc, &mut app).await;
assert!(!app.log_previous);
assert_eq!(
app.get_current_route().active_block,
ActiveBlock::Containers
);
}
#[tokio::test]
async fn test_tab_switch_deactivates_resource_filter_but_preserves_text() {
let mut app = App::default();
app.route_home();
app.data.pods.filter = "web".into();
app.data.pods.filter_active = true;
let key_evt = KeyEvent::from(KeyCode::Right);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.data.pods.filter, "web");
assert!(!app.data.pods.filter_active);
assert_eq!(app.get_current_route().active_block, ActiveBlock::Services);
}
#[tokio::test]
async fn test_namespace_filter_key_flow() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Namespaces);
let key_evt = KeyEvent::from(KeyCode::Char('/'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.ns_filter_active);
for c in ['p', 'r', 'o', 'd'] {
let key_evt = KeyEvent::from(KeyCode::Char(c));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
}
assert_eq!(app.ns_filter, "prod");
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.ns_filter_active);
assert!(app.ns_filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.ns_filter_active);
}
#[tokio::test]
async fn test_contexts_filter_key_flow() {
let mut app = App::default();
app.route_contexts();
assert!(!app.data.contexts.filter_active);
assert!(app.data.contexts.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Char('/'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.contexts.filter_active);
for c in ['p', 'r', 'o', 'd'] {
let key_evt = KeyEvent::from(KeyCode::Char(c));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
}
assert_eq!(app.data.contexts.filter, "prod");
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.contexts.filter_active);
assert!(app.data.contexts.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.data.contexts.filter_active);
}
#[tokio::test]
async fn test_utilization_filter_key_flow() {
let mut app = App::default();
app.route_utilization();
assert!(!app.data.metrics.filter_active);
assert!(app.data.metrics.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Char('/'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.metrics.filter_active);
for c in ['c', 'p', 'u'] {
let key_evt = KeyEvent::from(KeyCode::Char(c));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
}
assert_eq!(app.data.metrics.filter, "cpu");
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.metrics.filter_active);
assert!(app.data.metrics.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.data.metrics.filter_active);
}
#[tokio::test]
async fn test_troubleshoot_filter_key_flow() {
let mut app = App::default();
app.route_troubleshoot();
assert!(!app.data.troubleshoot_findings.filter_active);
assert!(app.data.troubleshoot_findings.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Char('/'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.troubleshoot_findings.filter_active);
for c in ['p', 'o', 'd'] {
let key_evt = KeyEvent::from(KeyCode::Char(c));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
}
assert_eq!(app.data.troubleshoot_findings.filter, "pod");
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.data.troubleshoot_findings.filter_active);
assert!(app.data.troubleshoot_findings.filter.is_empty());
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.data.troubleshoot_findings.filter_active);
}
#[tokio::test]
async fn test_help_scroll_keys_adjust_offset() {
let mut app = App::default();
app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::Help);
assert_eq!(app.help_scroll, 0);
let down = KeyEvent::from(KeyCode::Down);
handle_key_events(Key::from(down), down, &mut app).await;
assert_eq!(app.help_scroll, 1);
let up = KeyEvent::from(KeyCode::Up);
handle_key_events(Key::from(up), up, &mut app).await;
assert_eq!(app.help_scroll, 0);
handle_key_events(Key::from(up), up, &mut app).await;
assert_eq!(app.help_scroll, 0);
}
#[tokio::test]
async fn test_handle_describe_or_yaml_action() {
let mut app = App::default();
app.route_home();
assert_eq!(app.data.pods.state.selected(), None);
let item = KubePod::default();
assert!(
handle_describe_decode_or_yaml_action(
Key::Char('d'),
&mut app,
&item,
IoCmdEvent::GetDescribe {
kind: "pod".to_owned(),
value: "name".to_owned(),
ns: Some("namespace".to_owned()),
}
)
.await
);
assert_eq!(app.get_current_route().active_block, ActiveBlock::Describe);
assert_eq!(app.data.describe_out.get_txt(), "");
assert!(
handle_describe_decode_or_yaml_action(
Key::Char('y'),
&mut app,
&item,
IoCmdEvent::GetDescribe {
kind: "pod".to_owned(),
value: "name".to_owned(),
ns: Some("namespace".to_owned()),
}
)
.await
);
assert_eq!(app.get_current_route().active_block, ActiveBlock::Yaml);
assert_eq!(
app.data.describe_out.get_txt(),
"apiVersion: v1\nkind: Pod\nmetadata: {}\n"
);
assert!(
!handle_describe_decode_or_yaml_action(
Key::Char('s'),
&mut app,
&item,
IoCmdEvent::GetDescribe {
kind: "pod".to_owned(),
value: "name".to_owned(),
ns: Some("namespace".to_owned()),
}
)
.await
);
}
#[tokio::test]
async fn test_decode_secret() {
const DATA1: &str = "Hello, World!";
const DATA2: &str =
"Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit";
let mut app = App::default();
app.route_home();
let mut secret = KubeSecret::default();
secret
.data
.insert(String::from("key1"), ByteString(DATA1.as_bytes().into()));
secret
.data
.insert(String::from("key2"), ByteString(DATA2.as_bytes().into()));
assert!(
handle_describe_decode_or_yaml_action(
Key::Char('x'),
&mut app,
&secret,
IoCmdEvent::GetDescribe {
kind: "secret".to_owned(),
value: "name".to_owned(),
ns: Some("namespace".to_owned()),
}
)
.await
);
assert!(app
.data
.describe_out
.get_txt()
.contains(format!("key1: {}", DATA1).as_str()));
assert!(app
.data
.describe_out
.get_txt()
.contains(format!("key2: {}", DATA2).as_str()));
}
#[tokio::test]
async fn test_handle_scroll() {
let mut app = App::default();
app.route_home();
assert_eq!(app.data.pods.state.selected(), None);
app
.data
.pods
.set_items(vec![KubePod::default(), KubePod::default()]);
assert_eq!(app.data.pods.state.selected(), Some(0));
handle_block_scroll(&mut app, ScrollEvent::down(), true).await;
assert_eq!(app.data.pods.state.selected(), Some(1));
handle_block_scroll(&mut app, ScrollEvent::up(), true).await;
assert_eq!(app.data.pods.state.selected(), Some(0));
handle_block_scroll(&mut app, ScrollEvent::End, false).await;
assert_eq!(app.data.pods.state.selected(), Some(1));
app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
assert_eq!(app.data.logs.state.selected(), None);
app.data.logs.add_record("record".to_string());
app.data.logs.add_record("record 2".to_string());
app.data.logs.add_record("record 3".to_string());
handle_block_scroll(&mut app, ScrollEvent::down(), false).await;
assert_eq!(app.data.logs.state.selected(), Some(0));
}
#[tokio::test]
async fn test_context_switch() {
let mut app = App::default();
let ctx = KubeContext {
name: "test".into(),
..KubeContext::default()
};
app.data.contexts.set_items(vec![ctx]);
assert_eq!(app.data.selected.context, None);
app.route_contexts();
handle_route_events(Key::Enter, &mut app).await;
assert_eq!(app.data.selected.context, Some("test".into()));
assert!(app.refresh);
}
#[tokio::test]
async fn test_context_switch_preselects_namespace() {
let mut app = App::default();
let ctx = KubeContext {
name: "prod".into(),
namespace: Some("prod-ns".into()),
..KubeContext::default()
};
app.data.contexts.set_items(vec![ctx]);
assert_eq!(app.data.selected.ns, None);
app.route_contexts();
handle_route_events(Key::Enter, &mut app).await;
assert_eq!(app.data.selected.context, Some("prod".into()));
assert_eq!(app.data.selected.ns, Some("prod-ns".into()));
assert!(app.refresh);
}
#[tokio::test]
async fn test_context_switch_no_namespace_clears_ns() {
let mut app = App::default();
app.data.selected.ns = Some("old-ns".into());
let ctx = KubeContext {
name: "dev".into(),
namespace: None,
..KubeContext::default()
};
app.data.contexts.set_items(vec![ctx]);
app.route_contexts();
handle_route_events(Key::Enter, &mut app).await;
assert_eq!(app.data.selected.context, Some("dev".into()));
assert_eq!(app.data.selected.ns, None);
assert!(app.refresh);
}
#[test]
fn test_filter_menu_items_empty_filter_returns_all() {
let items = vec![
("CronJobs".into(), ActiveBlock::CronJobs),
("Secrets".into(), ActiveBlock::Secrets),
("Roles".into(), ActiveBlock::Roles),
];
let filtered = filter_menu_items(&items, "");
assert_eq!(filtered.len(), 3);
}
#[test]
fn test_filter_menu_items_substring_match() {
let items = vec![
("CronJobs".into(), ActiveBlock::CronJobs),
("Secrets".into(), ActiveBlock::Secrets),
("Roles".into(), ActiveBlock::Roles),
];
let filtered = filter_menu_items(&items, "cron");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].1 .0, "CronJobs");
}
#[test]
fn test_filter_menu_items_case_insensitive() {
let items = vec![
("CronJobs".into(), ActiveBlock::CronJobs),
("Secrets".into(), ActiveBlock::Secrets),
];
let filtered = filter_menu_items(&items, "CRON");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].1 .0, "CronJobs");
}
#[test]
fn test_filter_menu_items_glob_match() {
let items = vec![
("ClusterRoles".into(), ActiveBlock::ClusterRoles),
(
"ClusterRoleBinding".into(),
ActiveBlock::ClusterRoleBindings,
),
("CronJobs".into(), ActiveBlock::CronJobs),
];
let filtered = filter_menu_items(&items, "cluster*");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].1 .0, "ClusterRoles");
assert_eq!(filtered[1].1 .0, "ClusterRoleBinding");
}
#[test]
fn test_filter_menu_items_no_match() {
let items = vec![
("CronJobs".into(), ActiveBlock::CronJobs),
("Secrets".into(), ActiveBlock::Secrets),
];
let filtered = filter_menu_items(&items, "zzz");
assert_eq!(filtered.len(), 0);
}
#[test]
fn test_filter_menu_items_preserves_original_index() {
let items = vec![
("CronJobs".into(), ActiveBlock::CronJobs),
("Secrets".into(), ActiveBlock::Secrets),
("Roles".into(), ActiveBlock::Roles),
];
let filtered = filter_menu_items(&items, "role");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, 2); }
#[tokio::test]
async fn test_menu_filter_captures_character_keys() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
let key_evt = KeyEvent::from(KeyCode::Char('/'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(app.menu_filter_active);
let key_evt = KeyEvent::from(KeyCode::Char('c'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "c");
let key_evt = KeyEvent::from(KeyCode::Char('r'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "cr");
}
#[tokio::test]
async fn test_menu_filter_requires_slash_to_activate() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
let key_evt = KeyEvent::from(KeyCode::Char('c'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "");
assert!(!app.menu_filter_active);
}
#[tokio::test]
async fn test_menu_filter_backspace_removes_char() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
app.menu_filter_active = true;
let key_evt = KeyEvent::from(KeyCode::Char('a'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
let key_evt = KeyEvent::from(KeyCode::Char('b'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "ab");
let key_evt = KeyEvent::from(KeyCode::Backspace);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "a");
}
#[tokio::test]
async fn test_menu_filter_backspace_on_empty_does_not_panic() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
app.menu_filter_active = true;
let key_evt = KeyEvent::from(KeyCode::Backspace);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "");
}
#[tokio::test]
async fn test_menu_filter_escape_clears_filter_first() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
app.menu_filter_active = true;
let key_evt = KeyEvent::from(KeyCode::Char('x'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "x");
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "");
assert!(app.menu_filter_active); assert_eq!(app.get_current_route().active_block, ActiveBlock::More);
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert!(!app.menu_filter_active);
assert_eq!(app.get_current_route().active_block, ActiveBlock::More);
}
#[tokio::test]
async fn test_menu_filter_escape_on_empty_closes_menu() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "");
}
#[tokio::test]
async fn test_menu_filter_enter_selects_filtered_item() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
app.menu_filter_active = true;
for c in "cron".chars() {
let key_evt = KeyEvent::from(KeyCode::Char(c));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
}
assert_eq!(app.menu_filter, "cron");
assert_eq!(app.more_resources_menu.state.selected(), Some(0));
let key_evt = KeyEvent::from(KeyCode::Enter);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.menu_filter, "");
assert!(!app.menu_filter_active);
assert_eq!(app.get_current_route().active_block, ActiveBlock::CronJobs);
}
#[test]
fn test_handle_menu_scroll_within_filtered_bounds() {
let mut menu = StatefulList::with_items(vec![
("A".into(), ActiveBlock::CronJobs),
("B".into(), ActiveBlock::Secrets),
("C".into(), ActiveBlock::Roles),
]);
menu.state.select(Some(0));
handle_menu_scroll(&mut menu, ScrollEvent::down(), 2);
assert_eq!(menu.state.selected(), Some(1));
handle_menu_scroll(&mut menu, ScrollEvent::down(), 2);
assert_eq!(menu.state.selected(), Some(0));
handle_menu_scroll(&mut menu, ScrollEvent::up(), 2);
assert_eq!(menu.state.selected(), Some(1));
}
#[test]
fn test_handle_menu_scroll_empty_filtered() {
let mut menu = StatefulList::with_items(vec![("A".into(), ActiveBlock::CronJobs)]);
menu.state.select(Some(0));
handle_menu_scroll(&mut menu, ScrollEvent::Relative(0), 0);
assert_eq!(menu.state.selected(), Some(0));
}
#[tokio::test]
async fn test_dispatch_resource_pods_sets_selector_state() {
let mut app = App::default();
app.route_home();
app
.dispatch_resource_pods(
"default".into(),
"app=nginx".into(),
"deployment".into(),
RouteId::Home,
)
.await;
assert_eq!(app.data.selected.pod_selector, Some("app=nginx".into()));
assert_eq!(app.data.selected.pod_selector_ns, Some("default".into()));
assert_eq!(
app.data.selected.pod_selector_resource,
Some("deployment".into())
);
assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
}
#[tokio::test]
async fn test_dispatch_aggregate_logs_sets_state() {
let mut app = App::default();
app.route_home();
app
.dispatch_aggregate_logs(
"my-deploy".into(),
"default".into(),
"app=nginx".into(),
"deployment".into(),
RouteId::Home,
)
.await;
assert_eq!(app.data.logs.id, "agg:my-deploy");
assert_eq!(
app.data.selected.pod_selector_resource,
Some("deployment".into())
);
assert_eq!(app.get_current_route().active_block, ActiveBlock::Logs);
}
#[tokio::test]
async fn test_escape_from_more_selected_resource_returns_to_more_not_dynamic() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::DynamicView);
app.push_navigation_stack(RouteId::Home, ActiveBlock::More);
app.push_navigation_stack(RouteId::Home, ActiveBlock::Secrets);
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.get_current_route().active_block, ActiveBlock::More);
}
#[tokio::test]
async fn test_reset_navigation_key_clears_stack_to_home() {
let mut app = App::default();
app.route_utilization();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Describe);
app.push_navigation_stack(RouteId::Home, ActiveBlock::Yaml);
let reset_key = DEFAULT_KEYBINDING.reset_navigation.key;
let key_evt = KeyEvent::from(KeyCode::Char('h')); handle_key_events(reset_key, key_evt, &mut app).await;
assert_eq!(app.get_current_route().id, RouteId::Home);
assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
assert_eq!(app.pop_navigation_stack(), None);
}
#[tokio::test]
async fn test_escape_from_filtered_pods_clears_selector_state() {
let mut app = App::default();
app.route_home();
app.data.selected.pod_selector = Some("app=nginx".into());
app.data.selected.pod_selector_ns = Some("default".into());
app.data.selected.pod_selector_resource = Some("deployment".into());
app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.data.selected.pod_selector, None);
assert_eq!(app.data.selected.pod_selector_ns, None);
assert_eq!(app.data.selected.pod_selector_resource, None);
}
#[tokio::test]
async fn test_escape_from_aggregate_logs_clears_resource_context() {
let mut app = App::default();
app.route_home();
app.data.selected.pod_selector_resource = Some("deployment".into());
app.data.logs = crate::app::models::LogsState::new("agg:my-deploy".into());
app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.data.selected.pod_selector_resource, None);
}
#[tokio::test]
async fn test_escape_from_drilldown_logs_preserves_resource_context() {
let mut app = App::default();
app.route_home();
app.data.selected.pod_selector = Some("app=nginx".into());
app.data.selected.pod_selector_ns = Some("default".into());
app.data.selected.pod_selector_resource = Some("deployment".into());
app.push_navigation_stack(RouteId::Home, ActiveBlock::Pods);
app.push_navigation_stack(RouteId::Home, ActiveBlock::Containers);
app.push_navigation_stack(RouteId::Home, ActiveBlock::Logs);
let key_evt = KeyEvent::from(KeyCode::Esc);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(
app.data.selected.pod_selector_resource,
Some("deployment".into())
);
assert_eq!(app.data.selected.pod_selector, Some("app=nginx".into()));
}
#[tokio::test]
async fn test_jump_to_pods_clears_selector_state() {
let mut app = App::default();
app.route_home();
app.data.selected.pod_selector = Some("app=nginx".into());
app.data.selected.pod_selector_ns = Some("default".into());
app.data.selected.pod_selector_resource = Some("deployment".into());
let key_evt = KeyEvent::from(KeyCode::Char('1'));
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.data.selected.pod_selector, None);
assert_eq!(app.data.selected.pod_selector_ns, None);
assert_eq!(app.data.selected.pod_selector_resource, None);
assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
}
#[tokio::test]
async fn test_enter_on_leaf_resource_runs_describe() {
let mut app = App::default();
app.route_home();
app.push_navigation_stack(RouteId::Home, ActiveBlock::Secrets);
let mut secret = KubeSecret::default();
secret.name = "my-secret".into();
secret.namespace = "default".into();
app.data.secrets.set_items(vec![secret]);
let key_evt = KeyEvent::from(KeyCode::Enter);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.get_current_route().active_block, ActiveBlock::Describe);
}
#[tokio::test]
async fn test_dispatch_node_pods_sets_state() {
let mut app = App::default();
app.route_home();
app
.dispatch_node_pods("my-node-01".into(), RouteId::Home)
.await;
assert_eq!(app.data.selected.pod_selector, Some("my-node-01".into()));
assert_eq!(app.data.selected.pod_selector_ns, None);
assert_eq!(app.data.selected.pod_selector_resource, Some("node".into()));
assert_eq!(app.get_current_route().active_block, ActiveBlock::Pods);
}
#[tokio::test]
async fn test_dynamic_view_selection_uses_cached_items_immediately() {
let mut app = App::default();
app.push_navigation_stack(RouteId::Home, ActiveBlock::DynamicView);
app.dynamic_resources_menu =
StatefulList::with_items(vec![("Widget".into(), ActiveBlock::DynamicResource)]);
app.dynamic_resources_menu.state.select(Some(0));
let kind = KubeDynamicKind::new(
ApiResource {
group: "example.com".into(),
version: "v1".into(),
api_version: "example.com/v1".into(),
kind: "Widget".into(),
plural: "widgets".into(),
},
Scope::Namespaced,
);
app.data.dynamic_kinds = vec![kind.clone()];
app.data.selected.ns = Some("team-a".into());
let cached_items = vec![KubeDynamicResource::from(DynamicObject {
types: None,
metadata: ObjectMeta {
name: Some("widget-1".into()),
namespace: Some("team-a".into()),
..Default::default()
},
data: Default::default(),
})];
app.data.dynamic_resource_cache.insert(
dynamic_cache_key(&kind, Some("team-a")),
cached_items.clone(),
);
let key_evt = KeyEvent::from(KeyCode::Enter);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(
app.get_current_route().active_block,
ActiveBlock::DynamicResource
);
assert_eq!(
app
.data
.selected
.dynamic_kind
.as_ref()
.map(|it| it.kind.as_str()),
Some("Widget")
);
assert_eq!(app.data.dynamic_resources.items, cached_items);
}
#[tokio::test]
async fn test_enter_on_dynamic_resource_runs_describe() {
let (sync_io_tx, _sync_io_rx) = mpsc::channel(10);
let (sync_io_stream_tx, _sync_io_stream_rx) = mpsc::channel(10);
let (sync_io_cmd_tx, mut sync_io_cmd_rx) = mpsc::channel::<IoCmdEvent>(10);
let mut app = App::new(
sync_io_tx,
sync_io_stream_tx,
sync_io_cmd_tx,
false,
1,
App::default().log_tail_lines,
crate::config::KdashConfig::default(),
);
app.push_navigation_stack(RouteId::Home, ActiveBlock::DynamicResource);
let kind = KubeDynamicKind::new(
ApiResource {
group: "example.com".into(),
version: "v1".into(),
api_version: "example.com/v1".into(),
kind: "Widget".into(),
plural: "widgets".into(),
},
Scope::Namespaced,
);
app.data.selected.dynamic_kind = Some(kind);
app.data.dynamic_resources =
StatefulTable::with_items(vec![KubeDynamicResource::from(DynamicObject {
types: None,
metadata: ObjectMeta {
name: Some("widget-1".into()),
namespace: Some("team-a".into()),
..Default::default()
},
data: Default::default(),
})]);
let key_evt = KeyEvent::from(KeyCode::Enter);
handle_key_events(Key::from(key_evt), key_evt, &mut app).await;
assert_eq!(app.get_current_route().active_block, ActiveBlock::Describe);
assert_eq!(
sync_io_cmd_rx.recv().await.unwrap(),
IoCmdEvent::GetDescribe {
kind: "Widget".into(),
value: "widget-1".into(),
ns: Some("team-a".into()),
}
);
}
}