use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo::{Config, Matcher, Utf32Str};
use tokio_util::sync::CancellationToken;
use muxtop_core::actions::{self, Signal};
use muxtop_core::network::NetworkHistory;
use muxtop_core::process::{
ProcessInfo, SortField, SortOrder, build_process_tree, filter_processes, flatten_tree,
sort_processes,
};
use muxtop_core::system::SystemSnapshot;
use crate::terminal::TermCaps;
use crate::ui::sanitize::scrub_ctrl;
use crate::{CliConfig, ConnectionMode};
#[derive(Debug, Clone)]
pub enum ConfirmAction {
Kill {
pid: u32,
name: String,
signal: Signal,
},
Renice { pid: u32, name: String, delta: i32 },
StopContainer { id: String, name: String },
KillContainer { id: String, name: String },
RestartContainer { id: String, name: String },
}
impl ConfirmAction {
pub fn prompt(&self) -> String {
match self {
ConfirmAction::Kill { pid, name, signal } => {
let sig_name = match signal {
Signal::Kill => "SIGKILL",
Signal::Term => "SIGTERM",
};
let name = scrub_ctrl(name);
format!("Send {sig_name} to {name} (PID {pid})? [y/n]")
}
ConfirmAction::Renice { pid, name, delta } => {
let direction = if *delta > 0 {
"lower priority (+1)"
} else {
"higher priority (-1)"
};
let name = scrub_ctrl(name);
format!("Renice {name} (PID {pid}) to {direction}? [y/n]")
}
ConfirmAction::StopContainer { id, name } => {
let name = scrub_ctrl(name);
format!("Stop container {name} ({})? [y/n]", short_id(id))
}
ConfirmAction::KillContainer { id, name } => {
let name = scrub_ctrl(name);
format!("Kill container {name} ({})? [y/n]", short_id(id))
}
ConfirmAction::RestartContainer { id, name } => {
let name = scrub_ctrl(name);
format!("Restart container {name} ({})? [y/n]", short_id(id))
}
}
}
}
fn short_id(id: &str) -> String {
id.chars().take(12).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Quit,
ToggleTreeView,
SortByCpu,
SortByMem,
SortByPid,
SortByName,
SortByUser,
ToggleSortOrder,
CycleSort,
SwitchToGeneral,
SwitchToProcesses,
OpenFilter,
NextTab,
PrevTab,
KillProcess,
ForceKillProcess,
NiceDown,
NiceUp,
ClearFilter,
SwitchToNetwork,
SortNetByRx,
SortNetByTx,
SortNetByName,
SortNetByErrors,
SwitchToContainers,
SortContainersByCpu,
SortContainersByMem,
SortContainersByName,
SortContainersByNetRx,
StopContainer,
KillContainer,
RestartContainer,
SwitchToKube,
}
impl Command {
pub const ALL: &[Command] = &[
Command::Quit,
Command::ToggleTreeView,
Command::SortByCpu,
Command::SortByMem,
Command::SortByPid,
Command::SortByName,
Command::SortByUser,
Command::ToggleSortOrder,
Command::CycleSort,
Command::SwitchToGeneral,
Command::SwitchToProcesses,
Command::OpenFilter,
Command::NextTab,
Command::PrevTab,
Command::KillProcess,
Command::ForceKillProcess,
Command::NiceDown,
Command::NiceUp,
Command::ClearFilter,
Command::SwitchToNetwork,
Command::SortNetByRx,
Command::SortNetByTx,
Command::SortNetByName,
Command::SortNetByErrors,
Command::SwitchToContainers,
Command::SortContainersByCpu,
Command::SortContainersByMem,
Command::SortContainersByName,
Command::SortContainersByNetRx,
Command::StopContainer,
Command::KillContainer,
Command::RestartContainer,
Command::SwitchToKube,
];
pub fn label(self) -> &'static str {
match self {
Command::Quit => "Quit",
Command::ToggleTreeView => "Toggle tree view",
Command::SortByCpu => "Sort by CPU",
Command::SortByMem => "Sort by Memory",
Command::SortByPid => "Sort by PID",
Command::SortByName => "Sort by Name",
Command::SortByUser => "Sort by User",
Command::ToggleSortOrder => "Toggle sort order",
Command::CycleSort => "Cycle sort field",
Command::SwitchToGeneral => "Switch to General tab",
Command::SwitchToProcesses => "Switch to Processes tab",
Command::OpenFilter => "Open filter",
Command::NextTab => "Next tab",
Command::PrevTab => "Previous tab",
Command::KillProcess => "Kill process (SIGTERM)",
Command::ForceKillProcess => "Force kill (SIGKILL)",
Command::NiceDown => "Lower priority (+1)",
Command::NiceUp => "Raise priority (-1)",
Command::ClearFilter => "Clear filter",
Command::SwitchToNetwork => "Switch to Network tab",
Command::SortNetByRx => "Sort network by RX",
Command::SortNetByTx => "Sort network by TX",
Command::SortNetByName => "Sort network by name",
Command::SortNetByErrors => "Sort network by errors",
Command::SwitchToContainers => "Switch to Containers tab",
Command::SortContainersByCpu => "Sort containers by CPU",
Command::SortContainersByMem => "Sort containers by memory",
Command::SortContainersByName => "Sort containers by name",
Command::SortContainersByNetRx => "Sort containers by network RX",
Command::StopContainer => "Stop container (SIGTERM)",
Command::KillContainer => "Kill container (SIGKILL)",
Command::RestartContainer => "Restart container",
Command::SwitchToKube => "Switch to Kubernetes tab",
}
}
pub fn shortcut(self) -> &'static str {
match self {
Command::Quit => "q",
Command::ToggleTreeView => "t",
Command::SortByCpu => "F3",
Command::SortByMem => "F4",
Command::SortByPid => "F1",
Command::SortByName => "F2",
Command::SortByUser => "F5",
Command::ToggleSortOrder => "S",
Command::CycleSort => "s",
Command::SwitchToGeneral => "Alt+1",
Command::SwitchToProcesses => "Alt+2",
Command::OpenFilter => "/",
Command::NextTab => "Tab",
Command::PrevTab => "Shift+Tab",
Command::KillProcess => "F9",
Command::ForceKillProcess => "F10",
Command::NiceDown => "F7",
Command::NiceUp => "F8",
Command::ClearFilter => "Esc",
Command::SwitchToNetwork => "Alt+3",
Command::SortNetByRx => "",
Command::SortNetByTx => "",
Command::SortNetByName => "",
Command::SortNetByErrors => "",
Command::SwitchToContainers => "Alt+4",
Command::SortContainersByCpu => "",
Command::SortContainersByMem => "",
Command::SortContainersByName => "",
Command::SortContainersByNetRx => "",
Command::StopContainer => "F9",
Command::KillContainer => "F10",
Command::RestartContainer => "F11",
Command::SwitchToKube => "Alt+5",
}
}
fn search_text(self) -> String {
format!("{} {}", self.label(), self.shortcut())
}
pub fn search_texts() -> &'static [String] {
use std::sync::OnceLock;
static SEARCH_TEXTS: OnceLock<Vec<String>> = OnceLock::new();
SEARCH_TEXTS.get_or_init(|| Self::ALL.iter().map(|c| c.search_text()).collect())
}
}
pub struct PaletteState {
pub input: String,
pub selected: usize,
pub filtered: Vec<(Command, Option<u16>)>,
matcher: Option<Matcher>,
}
impl Default for PaletteState {
fn default() -> Self {
Self::new()
}
}
impl PaletteState {
pub fn new() -> Self {
let filtered = Command::ALL.iter().map(|&cmd| (cmd, None)).collect();
Self {
input: String::new(),
selected: 0,
filtered,
matcher: None,
}
}
pub fn refilter_excluding(&mut self, excluded: &[Command]) {
let is_available = |cmd: &Command| !excluded.contains(cmd);
if self.input.is_empty() {
self.filtered.clear();
self.filtered.extend(
Command::ALL
.iter()
.copied()
.filter(is_available)
.map(|cmd| (cmd, None)),
);
} else {
let matcher = self
.matcher
.get_or_insert_with(|| Matcher::new(Config::DEFAULT));
let atom = Atom::new(
&self.input,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
let haystacks = Command::search_texts();
let mut scored: Vec<(Command, u16)> = Command::ALL
.iter()
.copied()
.enumerate()
.filter(|(_, cmd)| is_available(cmd))
.filter_map(|(idx, cmd)| {
let haystack = &haystacks[idx];
let mut buf = Vec::new();
let haystack_utf32 = Utf32Str::new(haystack, &mut buf);
atom.score(haystack_utf32, matcher)
.map(|score| (cmd, score))
})
.collect();
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
self.filtered.clear();
self.filtered
.extend(scored.into_iter().map(|(cmd, s)| (cmd, Some(s))));
}
if self.filtered.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.filtered.len() - 1);
}
}
pub fn refilter(&mut self) {
self.refilter_excluding(&[]);
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
#[default]
General,
Processes,
Network,
Containers,
Kube,
}
impl std::fmt::Display for Tab {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
impl Tab {
pub const ALL: &[Tab] = &[
Tab::General,
Tab::Processes,
Tab::Network,
Tab::Containers,
Tab::Kube,
];
pub fn label(self) -> &'static str {
match self {
Tab::General => "General",
Tab::Processes => "Processes",
Tab::Network => "Network",
Tab::Containers => "Containers",
Tab::Kube => "Kube",
}
}
pub fn next(self) -> Tab {
let idx = Self::ALL.iter().position(|&t| t == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Tab {
let idx = Self::ALL.iter().position(|&t| t == self).unwrap_or(0);
Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum NetworkSortField {
Name,
#[default]
RxRate,
TxRate,
TotalRx,
TotalTx,
Errors,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ContainerSortField {
Name,
#[default]
Cpu,
Mem,
NetRx,
NetTx,
Uptime,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum KubeSubview {
#[default]
Pods,
Nodes,
Deployments,
}
impl KubeSubview {
pub fn label(self) -> &'static str {
match self {
KubeSubview::Pods => "Pods",
KubeSubview::Nodes => "Nodes",
KubeSubview::Deployments => "Deployments",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KubeSortField {
PodName,
PodCpu,
PodMem,
PodRestarts,
PodAge,
PodPhase,
NodeName,
NodeCpuPct,
NodeMemPct,
NodePodCount,
NodeAge,
DeployNamespace,
DeployName,
DeployReadyRatio,
DeployAge,
}
impl KubeSortField {
pub fn default_for(sv: KubeSubview) -> Self {
match sv {
KubeSubview::Pods => KubeSortField::PodCpu,
KubeSubview::Nodes => KubeSortField::NodeCpuPct,
KubeSubview::Deployments => KubeSortField::DeployName,
}
}
}
const STATUS_TIMEOUT_SECS: u64 = 5;
const FILTER_DEBOUNCE: Duration = Duration::from_millis(50);
pub struct AppState {
pub tab: Tab,
pub sort_field: SortField,
pub sort_order: SortOrder,
pub filter_input: String,
pub filter_active: bool,
pub tree_mode: bool,
pub selected: usize,
pub scroll_offset: usize,
pub show_palette: bool,
pub palette: PaletteState,
pub confirm: Option<ConfirmAction>,
pub status_message: Option<(String, Instant)>,
pub term_caps: TermCaps,
running: bool,
pub last_snapshot: Option<SystemSnapshot>,
pub visible_processes: Vec<ProcessInfo>,
pub visible_tree: Vec<(ProcessInfo, usize)>,
pub network_history: NetworkHistory,
pub net_selected: usize,
pub net_scroll_offset: usize,
pub net_sort_field: NetworkSortField,
pub net_sort_order: SortOrder,
pub net_filter_input: String,
pub net_filter_active: bool,
pub containers_selected: usize,
pub containers_scroll_offset: usize,
pub kube_subview: KubeSubview,
pub kube_sort_field: KubeSortField,
pub kube_sort_order: SortOrder,
pub kube_filter_input: String,
pub kube_filter_active: bool,
pub kube_selected: usize,
pub kube_scroll_offset: usize,
pub containers_sort_field: ContainerSortField,
pub containers_sort_order: SortOrder,
pub containers_filter_input: String,
pub containers_filter_active: bool,
container_cpu_hist: std::collections::HashMap<String, std::collections::VecDeque<f32>>,
container_rx_last: std::collections::HashMap<String, u64>,
container_rx_hist: std::collections::HashMap<String, std::collections::VecDeque<u64>>,
sorted_filtered_containers_cache: Vec<muxtop_core::containers::ContainerSnapshot>,
pub container_engine:
Option<std::sync::Arc<dyn muxtop_core::container_engine::ContainerEngine + Send + Sync>>,
pub cluster_engine:
Option<std::sync::Arc<dyn muxtop_core::cluster_engine::ClusterEngine + Send + Sync>>,
action_tx: tokio::sync::mpsc::UnboundedSender<String>,
action_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
pub connection_mode: ConnectionMode,
needs_redraw: bool,
last_filter_change: Option<Instant>,
shutdown_token: CancellationToken,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel();
Self {
tab: Tab::default(),
sort_field: SortField::Cpu,
sort_order: SortOrder::Desc,
filter_input: String::new(),
filter_active: false,
tree_mode: false,
selected: 0,
scroll_offset: 0,
show_palette: false,
palette: PaletteState::new(),
confirm: None,
status_message: None,
term_caps: TermCaps::default(),
running: true,
last_snapshot: None,
visible_processes: Vec::new(),
visible_tree: Vec::new(),
network_history: NetworkHistory::new(60),
net_selected: 0,
net_scroll_offset: 0,
net_sort_field: NetworkSortField::default(),
net_sort_order: SortOrder::Desc,
net_filter_input: String::new(),
net_filter_active: false,
containers_selected: 0,
containers_scroll_offset: 0,
kube_subview: KubeSubview::default(),
kube_sort_field: KubeSortField::default_for(KubeSubview::default()),
kube_sort_order: SortOrder::Desc,
kube_filter_input: String::new(),
kube_filter_active: false,
kube_selected: 0,
kube_scroll_offset: 0,
containers_sort_field: ContainerSortField::default(),
containers_sort_order: SortOrder::Desc,
containers_filter_input: String::new(),
containers_filter_active: false,
container_cpu_hist: std::collections::HashMap::new(),
container_rx_last: std::collections::HashMap::new(),
container_rx_hist: std::collections::HashMap::new(),
sorted_filtered_containers_cache: Vec::new(),
container_engine: None,
cluster_engine: None,
action_tx,
action_rx,
connection_mode: ConnectionMode::default(),
needs_redraw: true,
last_filter_change: None,
shutdown_token: CancellationToken::new(),
}
}
pub fn with_config(config: CliConfig, term_caps: TermCaps) -> Self {
Self {
sort_field: config.sort_field,
tree_mode: config.tree_mode,
filter_input: config.filter.unwrap_or_default(),
term_caps,
connection_mode: config.connection_mode,
..Self::new()
}
}
pub fn is_remote(&self) -> bool {
matches!(self.connection_mode, ConnectionMode::Remote { .. })
}
pub fn active_status(&self) -> Option<&str> {
self.status_message.as_ref().and_then(|(msg, created)| {
if created.elapsed().as_secs() < STATUS_TIMEOUT_SECS {
Some(msg.as_str())
} else {
None
}
})
}
fn set_status(&mut self, msg: String) {
let msg = scrub_ctrl(&msg).into_owned();
self.status_message = Some((msg, Instant::now()));
self.needs_redraw = true;
}
fn clear_status_if_expired(&mut self) {
if let Some((_, created)) = &self.status_message
&& created.elapsed().as_secs() >= STATUS_TIMEOUT_SECS
{
self.status_message = None;
}
}
pub fn status_message_just_expired(&mut self) -> bool {
let expired = self
.status_message
.as_ref()
.is_some_and(|(_, t)| t.elapsed().as_secs() >= STATUS_TIMEOUT_SECS);
if expired {
self.status_message = None;
}
expired
}
pub fn running(&self) -> bool {
self.running
}
pub fn quit(&mut self) {
self.running = false;
self.shutdown_token.cancel();
}
fn mark_dirty(&mut self) {
self.needs_redraw = true;
}
pub fn take_needs_redraw(&mut self) -> bool {
std::mem::replace(&mut self.needs_redraw, false)
}
pub fn shutdown_token(&self) -> CancellationToken {
self.shutdown_token.clone()
}
pub fn process_count(&self) -> usize {
if self.tree_mode {
self.visible_tree.len()
} else {
self.visible_processes.len()
}
}
pub fn net_interface_count(&self) -> usize {
let Some(ref snapshot) = self.last_snapshot else {
return 0;
};
if self.net_filter_input.is_empty() {
snapshot.networks.interfaces.len()
} else {
let filter = self.net_filter_input.to_lowercase();
snapshot
.networks
.interfaces
.iter()
.filter(|i| i.name.to_lowercase().contains(&filter))
.count()
}
}
pub fn item_count(&self) -> usize {
match self.tab {
Tab::Network => self.net_interface_count(),
Tab::Containers => self.containers_count(),
Tab::Kube => self.kube_count(),
_ => self.process_count(),
}
}
fn selected_mut(&mut self) -> (&mut usize, &mut usize) {
match self.tab {
Tab::Network => (&mut self.net_selected, &mut self.net_scroll_offset),
Tab::Containers => (
&mut self.containers_selected,
&mut self.containers_scroll_offset,
),
Tab::Kube => (&mut self.kube_selected, &mut self.kube_scroll_offset),
_ => (&mut self.selected, &mut self.scroll_offset),
}
}
pub fn kube_count(&self) -> usize {
let Some(snap) = self.last_snapshot.as_ref().and_then(|s| s.kube.as_ref()) else {
return 0;
};
let f = self.kube_filter_input.to_lowercase();
match self.kube_subview {
KubeSubview::Pods => snap
.pods
.iter()
.filter(|p| {
f.is_empty()
|| p.name.to_lowercase().contains(&f)
|| p.namespace.to_lowercase().contains(&f)
})
.count(),
KubeSubview::Nodes => snap
.nodes
.iter()
.filter(|n| f.is_empty() || n.name.to_lowercase().contains(&f))
.count(),
KubeSubview::Deployments => snap
.deployments
.iter()
.filter(|d| {
f.is_empty()
|| d.name.to_lowercase().contains(&f)
|| d.namespace.to_lowercase().contains(&f)
})
.count(),
}
}
pub fn switch_kube_subview(&mut self, sv: KubeSubview) {
self.kube_subview = sv;
self.kube_sort_field = KubeSortField::default_for(sv);
self.kube_sort_order = SortOrder::Desc;
self.kube_filter_input.clear();
self.kube_filter_active = false;
self.kube_selected = 0;
self.kube_scroll_offset = 0;
}
pub fn selected_process(&self) -> Option<&ProcessInfo> {
if self.tree_mode {
self.visible_tree.get(self.selected).map(|(p, _)| p)
} else {
self.visible_processes.get(self.selected)
}
}
pub fn set_container_engine(
&mut self,
engine: std::sync::Arc<dyn muxtop_core::container_engine::ContainerEngine + Send + Sync>,
) {
self.container_engine = Some(engine);
}
pub fn set_cluster_engine(
&mut self,
engine: std::sync::Arc<dyn muxtop_core::cluster_engine::ClusterEngine + Send + Sync>,
) {
self.cluster_engine = Some(engine);
}
pub fn toggle_kube_scope(&mut self) {
let Some(engine) = self.cluster_engine.clone() else {
self.set_status(
"Namespace scope is local-only (unavailable in remote mode / --no-kube)".into(),
);
return;
};
let tx = self.action_tx.clone();
let token = self.shutdown_token.clone();
tokio::spawn(async move {
let msg = tokio::select! {
biased;
_ = token.cancelled() => return,
scope = engine.toggle_scope() => match scope.namespace() {
Some(ns) => format!("Kube scope: namespace {ns}"),
None => "Kube scope: all namespaces".to_string(),
},
};
let _ = tx.send(msg);
});
self.kube_selected = 0;
self.kube_scroll_offset = 0;
self.needs_redraw = true;
}
pub fn selected_container(&self) -> Option<muxtop_core::containers::ContainerSnapshot> {
let snapshot = self.last_snapshot.as_ref()?;
let cs = snapshot.containers.as_ref()?;
if !cs.daemon_up {
return None;
}
self.sorted_filtered_containers_cache
.get(self.containers_selected)
.cloned()
}
pub fn sorted_filtered_containers(&self) -> &[muxtop_core::containers::ContainerSnapshot] {
&self.sorted_filtered_containers_cache
}
pub fn pump_action_results(&mut self) {
let mut had_any = false;
while let Ok(msg) = self.action_rx.try_recv() {
self.set_status(msg);
had_any = true;
}
if had_any {
self.mark_dirty();
}
}
fn recompute_containers_view(&mut self) {
use muxtop_core::containers::ContainerSnapshot;
self.sorted_filtered_containers_cache.clear();
let Some(snapshot) = self.last_snapshot.as_ref() else {
return;
};
let Some(cs) = snapshot.containers.as_ref() else {
return;
};
if !cs.daemon_up {
return;
}
let mut rows: Vec<ContainerSnapshot> = if self.containers_filter_input.is_empty() {
cs.containers.clone()
} else {
let f = self.containers_filter_input.to_lowercase();
cs.containers
.iter()
.filter(|c| {
c.name.to_lowercase().contains(&f)
|| c.image.to_lowercase().contains(&f)
|| c.id.to_lowercase().contains(&f)
})
.cloned()
.collect()
};
match self.containers_sort_field {
ContainerSortField::Name => rows.sort_by(|a, b| a.name.cmp(&b.name)),
ContainerSortField::Cpu => rows.sort_by(|a, b| {
b.cpu_pct
.partial_cmp(&a.cpu_pct)
.unwrap_or(std::cmp::Ordering::Equal)
}),
ContainerSortField::Mem => rows.sort_by_key(|c| std::cmp::Reverse(c.mem_used_bytes)),
ContainerSortField::NetRx => rows.sort_by_key(|c| std::cmp::Reverse(c.net_rx_bytes)),
ContainerSortField::NetTx => rows.sort_by_key(|c| std::cmp::Reverse(c.net_tx_bytes)),
ContainerSortField::Uptime => rows.sort_by_key(|c| c.started_at_ms),
}
let is_asc = matches!(self.containers_sort_order, SortOrder::Asc);
let is_name = matches!(self.containers_sort_field, ContainerSortField::Name);
let is_uptime = matches!(self.containers_sort_field, ContainerSortField::Uptime);
let default_asc = is_name || is_uptime;
if is_asc != default_asc {
rows.reverse();
}
self.sorted_filtered_containers_cache = rows;
}
fn request_container_action(&mut self, build: impl FnOnce(String, String) -> ConfirmAction) {
if self.is_remote() {
self.set_status("Actions disabled in remote mode".into());
return;
}
if let Some(c) = self.selected_container() {
self.confirm = Some(build(c.id_full.clone(), c.name.clone()));
}
}
pub fn request_container_stop(&mut self) {
self.request_container_action(|id, name| ConfirmAction::StopContainer { id, name });
}
pub fn request_container_kill(&mut self) {
self.request_container_action(|id, name| ConfirmAction::KillContainer { id, name });
}
pub fn request_container_restart(&mut self) {
self.request_container_action(|id, name| ConfirmAction::RestartContainer { id, name });
}
fn execute_container_action(&mut self, action: ConfirmAction) {
let Some(engine) = self.container_engine.clone() else {
self.set_status("Container engine not configured".into());
return;
};
let tx = self.action_tx.clone();
let token = self.shutdown_token.clone();
match action {
ConfirmAction::StopContainer { id, name } => {
tokio::spawn(async move {
let msg = tokio::select! {
biased;
_ = token.cancelled() => return,
result = engine.stop(&id, Some(10)) => match result {
Ok(()) => format!("Container {name} stopped"),
Err(e) => format!("Failed to stop {name}: {e}"),
},
};
let _ = tx.send(msg);
});
}
ConfirmAction::KillContainer { id, name } => {
tokio::spawn(async move {
let msg = tokio::select! {
biased;
_ = token.cancelled() => return,
result = engine.kill(&id) => match result {
Ok(()) => format!("Container {name} killed"),
Err(e) => format!("Failed to kill {name}: {e}"),
},
};
let _ = tx.send(msg);
});
}
ConfirmAction::RestartContainer { id, name } => {
tokio::spawn(async move {
let msg = tokio::select! {
biased;
_ = token.cancelled() => return,
result = engine.restart(&id) => match result {
Ok(()) => format!("Container {name} restarted"),
Err(e) => format!("Failed to restart {name}: {e}"),
},
};
let _ = tx.send(msg);
});
}
_ => unreachable!("execute_container_action only handles container variants"),
}
}
pub fn apply_snapshot(&mut self, snapshot: SystemSnapshot) {
self.network_history.push(snapshot.networks.clone());
if let Some(cs) = snapshot.containers.as_ref() {
self.push_container_history(cs);
}
self.last_snapshot = Some(snapshot);
self.recompute_visible();
self.recompute_containers_view();
self.mark_dirty();
}
const CONTAINER_HISTORY_LEN: usize = 60;
fn push_container_history(&mut self, cs: &muxtop_core::containers::ContainersSnapshot) {
use std::collections::{HashMap, VecDeque};
let mut seen = std::collections::HashSet::<String>::with_capacity(cs.containers.len());
for c in &cs.containers {
seen.insert(c.id.clone());
let cpu_ring = self
.container_cpu_hist
.entry(c.id.clone())
.or_insert_with(|| VecDeque::with_capacity(Self::CONTAINER_HISTORY_LEN));
if cpu_ring.len() >= Self::CONTAINER_HISTORY_LEN {
cpu_ring.pop_front();
}
cpu_ring.push_back(c.cpu_pct);
let last = self.container_rx_last.get(&c.id).copied();
let delta = match last {
Some(prev) => c.net_rx_bytes.saturating_sub(prev),
None => 0,
};
self.container_rx_last.insert(c.id.clone(), c.net_rx_bytes);
let rx_ring = self
.container_rx_hist
.entry(c.id.clone())
.or_insert_with(|| VecDeque::with_capacity(Self::CONTAINER_HISTORY_LEN));
if rx_ring.len() >= Self::CONTAINER_HISTORY_LEN {
rx_ring.pop_front();
}
rx_ring.push_back(delta);
}
fn drop_missing<V>(map: &mut HashMap<String, V>, seen: &std::collections::HashSet<String>) {
map.retain(|k, _| seen.contains(k));
}
drop_missing(&mut self.container_cpu_hist, &seen);
drop_missing(&mut self.container_rx_hist, &seen);
drop_missing(&mut self.container_rx_last, &seen);
}
pub fn container_cpu_history(&self, id: &str) -> Vec<f32> {
self.container_cpu_hist
.get(id)
.map(|r| r.iter().copied().collect())
.unwrap_or_default()
}
pub fn container_rx_deltas(&self, id: &str) -> Vec<u64> {
self.container_rx_hist
.get(id)
.map(|r| r.iter().copied().collect())
.unwrap_or_default()
}
pub fn containers_count(&self) -> usize {
let Some(ref snapshot) = self.last_snapshot else {
return 0;
};
let Some(ref cs) = snapshot.containers else {
return 0;
};
if self.containers_filter_input.is_empty() {
cs.containers.len()
} else {
let f = self.containers_filter_input.to_lowercase();
cs.containers
.iter()
.filter(|c| {
c.name.to_lowercase().contains(&f)
|| c.image.to_lowercase().contains(&f)
|| c.id.to_lowercase().contains(&f)
})
.count()
}
}
pub fn recompute_visible(&mut self) {
let Some(ref snapshot) = self.last_snapshot else {
self.visible_processes.clear();
self.visible_tree.clear();
return;
};
let filtered = filter_processes(&snapshot.processes, &self.filter_input);
if self.tree_mode {
let tree = build_process_tree(&filtered);
self.visible_tree = flatten_tree(&tree);
} else {
self.visible_tree.clear();
}
let mut sorted = filtered;
sort_processes(&mut sorted, self.sort_field, self.sort_order);
self.visible_processes = sorted;
let count = self.process_count();
if count > 0 {
self.selected = self.selected.min(count - 1);
self.scroll_offset = self.scroll_offset.min(count - 1);
} else {
self.selected = 0;
self.scroll_offset = 0;
}
}
fn recompute_visible_debounced(&mut self) {
let now = Instant::now();
let should_recompute = self
.last_filter_change
.is_none_or(|last| now.duration_since(last) >= FILTER_DEBOUNCE);
self.last_filter_change = Some(now);
if should_recompute {
self.recompute_visible();
}
}
pub fn handle_key_event(&mut self, key: KeyEvent) {
self.clear_status_if_expired();
if self.confirm.is_some() {
self.handle_confirm_key(key);
return;
}
if self.show_palette {
self.handle_palette_key(key);
return;
}
if self.filter_active
|| self.net_filter_active
|| self.containers_filter_active
|| self.kube_filter_active
{
self.handle_filter_key(key);
return;
}
self.status_message = None;
match key.code {
KeyCode::Char('q') => self.quit(),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.quit(),
KeyCode::Down | KeyCode::Char('j') => {
let count = self.item_count();
if count > 0 {
let (sel, _) = self.selected_mut();
*sel = (*sel + 1).min(count - 1);
}
}
KeyCode::Up | KeyCode::Char('k') => {
let (sel, _) = self.selected_mut();
*sel = sel.saturating_sub(1);
}
KeyCode::PageDown => {
let count = self.item_count();
if count > 0 {
let (sel, _) = self.selected_mut();
*sel = (*sel + 20).min(count - 1);
}
}
KeyCode::PageUp => {
let (sel, _) = self.selected_mut();
*sel = sel.saturating_sub(20);
}
KeyCode::Home | KeyCode::Char('g') => {
let (sel, _) = self.selected_mut();
*sel = 0;
}
KeyCode::End | KeyCode::Char('G') => {
let count = self.item_count();
if count > 0 {
let (sel, _) = self.selected_mut();
*sel = count - 1;
}
}
KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => {
self.tab = Tab::General;
}
KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => {
self.tab = Tab::Processes;
}
KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => {
self.tab = Tab::Network;
}
KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => {
self.tab = Tab::Containers;
}
KeyCode::Char('5') if key.modifiers.contains(KeyModifiers::ALT) => {
self.tab = Tab::Kube;
}
KeyCode::Right => {
self.tab = self.tab.next();
}
KeyCode::Left => {
self.tab = self.tab.prev();
}
KeyCode::Tab => {
self.tab = self.tab.next();
}
KeyCode::BackTab => {
self.tab = self.tab.prev();
}
KeyCode::Char('t') => {
self.tree_mode = !self.tree_mode;
self.selected = 0;
self.scroll_offset = 0;
self.recompute_visible();
}
KeyCode::Char('s') => match self.tab {
Tab::Network => {
self.net_sort_field = next_net_sort_field(self.net_sort_field);
}
Tab::Containers => {
self.containers_sort_field =
next_container_sort_field(self.containers_sort_field);
self.recompute_containers_view();
}
Tab::Kube => {
self.kube_sort_field =
next_kube_sort_field(self.kube_sort_field, self.kube_subview);
self.kube_selected = 0;
self.kube_scroll_offset = 0;
}
_ => {
self.sort_field = next_sort_field(self.sort_field);
self.recompute_visible();
}
},
KeyCode::Char('S') | KeyCode::Char('I') => match self.tab {
Tab::Network => {
self.net_sort_order = match self.net_sort_order {
SortOrder::Asc => SortOrder::Desc,
SortOrder::Desc => SortOrder::Asc,
};
}
Tab::Containers => {
self.containers_sort_order = match self.containers_sort_order {
SortOrder::Asc => SortOrder::Desc,
SortOrder::Desc => SortOrder::Asc,
};
self.recompute_containers_view();
}
Tab::Kube => {
self.kube_sort_order = match self.kube_sort_order {
SortOrder::Asc => SortOrder::Desc,
SortOrder::Desc => SortOrder::Asc,
};
}
_ => {
self.sort_order = match self.sort_order {
SortOrder::Asc => SortOrder::Desc,
SortOrder::Desc => SortOrder::Asc,
};
self.recompute_visible();
}
},
KeyCode::Char('P') if self.tab == Tab::Kube => {
self.switch_kube_subview(KubeSubview::Pods);
}
KeyCode::Char('N') if self.tab == Tab::Kube => {
self.switch_kube_subview(KubeSubview::Nodes);
}
KeyCode::Char('D') if self.tab == Tab::Kube => {
self.switch_kube_subview(KubeSubview::Deployments);
}
KeyCode::Char('A') if self.tab == Tab::Kube => {
self.toggle_kube_scope();
}
KeyCode::F(1) => {
self.sort_field = SortField::Pid;
self.recompute_visible();
}
KeyCode::F(2) => {
self.sort_field = SortField::Name;
self.recompute_visible();
}
KeyCode::F(3) => {
self.sort_field = SortField::Cpu;
self.recompute_visible();
}
KeyCode::F(4) => {
self.sort_field = SortField::Mem;
self.recompute_visible();
}
KeyCode::F(5) => {
self.sort_field = SortField::User;
self.recompute_visible();
}
KeyCode::F(9) if self.tab == Tab::Processes => {
if self.is_remote() {
self.set_status("Actions disabled in remote mode".to_string());
} else {
self.request_kill(Signal::Term);
}
}
KeyCode::F(10) if self.tab == Tab::Processes => {
if self.is_remote() {
self.set_status("Actions disabled in remote mode".to_string());
} else {
self.request_kill(Signal::Kill);
}
}
KeyCode::F(7) if self.tab == Tab::Processes => {
if self.is_remote() {
self.set_status("Actions disabled in remote mode".to_string());
} else {
self.request_renice(1);
}
}
KeyCode::F(8) if self.tab == Tab::Processes => {
if self.is_remote() {
self.set_status("Actions disabled in remote mode".to_string());
} else {
self.request_renice(-1);
}
}
KeyCode::F(9) if self.tab == Tab::Containers => {
self.request_container_stop();
}
KeyCode::F(10) if self.tab == Tab::Containers => {
self.request_container_kill();
}
KeyCode::F(11) if self.tab == Tab::Containers => {
self.request_container_restart();
}
KeyCode::Esc => match self.tab {
Tab::Network => {
if !self.net_filter_input.is_empty() {
self.net_filter_input.clear();
}
}
Tab::Containers => {
if !self.containers_filter_input.is_empty() {
self.containers_filter_input.clear();
self.recompute_containers_view();
}
}
Tab::Kube => {
if !self.kube_filter_input.is_empty() {
self.kube_filter_input.clear();
self.kube_selected = 0;
self.kube_scroll_offset = 0;
}
}
_ => {
if !self.filter_input.is_empty() {
self.filter_input.clear();
self.recompute_visible();
}
}
},
KeyCode::Char('/') => match self.tab {
Tab::Network => self.net_filter_active = true,
Tab::Containers => self.containers_filter_active = true,
Tab::Kube => self.kube_filter_active = true,
_ => self.filter_active = true,
},
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.open_palette();
}
_ => {}
}
}
fn request_kill(&mut self, signal: Signal) {
if let Some(proc) = self.selected_process() {
self.confirm = Some(ConfirmAction::Kill {
pid: proc.pid,
name: proc.name.clone(),
signal,
});
}
}
fn request_renice(&mut self, delta: i32) {
if let Some(proc) = self.selected_process() {
self.confirm = Some(ConfirmAction::Renice {
pid: proc.pid,
name: proc.name.clone(),
delta,
});
}
}
fn handle_confirm_key(&mut self, key: KeyEvent) {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.quit();
return;
}
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(action) = self.confirm.take() {
self.execute_confirm(action);
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.confirm = None;
}
_ => {} }
}
fn execute_confirm(&mut self, action: ConfirmAction) {
match action {
ConfirmAction::Kill { pid, name, signal } => {
let sig_name = match signal {
Signal::Kill => "SIGKILL",
Signal::Term => "SIGTERM",
};
match actions::kill_process(pid, signal) {
Ok(()) => self.set_status(format!("Sent {sig_name} to {name} (PID {pid})")),
Err(e) => self.set_status(format!("Kill failed: {e}")),
}
}
ConfirmAction::Renice { pid, name, delta } => {
let current = actions::get_process_priority(pid).unwrap_or(0);
let new_nice = current + delta;
match actions::renice_process(pid, new_nice) {
Ok(()) => {
self.set_status(format!("Reniced {name} (PID {pid}) to nice={new_nice}"))
}
Err(e) => self.set_status(format!("Renice failed: {e}")),
}
}
action @ (ConfirmAction::StopContainer { .. }
| ConfirmAction::KillContainer { .. }
| ConfirmAction::RestartContainer { .. }) => {
self.execute_container_action(action);
}
}
}
fn excluded_commands(&self) -> &[Command] {
if self.is_remote() {
Self::REMOTE_BLOCKED_COMMANDS
} else {
&[]
}
}
fn open_palette(&mut self) {
self.show_palette = true;
self.palette = PaletteState::new();
let excluded = self.excluded_commands().to_vec();
self.palette.refilter_excluding(&excluded);
}
fn close_palette(&mut self) {
self.show_palette = false;
self.palette.input.clear();
self.palette.selected = 0;
}
fn handle_palette_key(&mut self, key: KeyEvent) {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.quit();
return;
}
match key.code {
KeyCode::Esc => {
self.close_palette();
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.close_palette();
}
KeyCode::Enter => {
if let Some(&(cmd, _)) = self.palette.filtered.get(self.palette.selected) {
self.close_palette();
self.execute_command(cmd);
}
}
KeyCode::Down if !self.palette.filtered.is_empty() => {
self.palette.selected =
(self.palette.selected + 1).min(self.palette.filtered.len() - 1);
}
KeyCode::Up => {
self.palette.selected = self.palette.selected.saturating_sub(1);
}
KeyCode::Backspace => {
self.palette.input.pop();
let excluded = self.excluded_commands().to_vec();
self.palette.refilter_excluding(&excluded);
}
KeyCode::Char(c) => {
self.palette.input.push(c);
let excluded = self.excluded_commands().to_vec();
self.palette.refilter_excluding(&excluded);
}
_ => {}
}
}
const REMOTE_BLOCKED_COMMANDS: &[Command] = &[
Command::KillProcess,
Command::ForceKillProcess,
Command::NiceDown,
Command::NiceUp,
Command::StopContainer,
Command::KillContainer,
Command::RestartContainer,
];
fn execute_command(&mut self, cmd: Command) {
if self.is_remote() && Self::REMOTE_BLOCKED_COMMANDS.contains(&cmd) {
self.set_status("Actions disabled in remote mode".to_string());
return;
}
match cmd {
Command::Quit => self.quit(),
Command::ToggleTreeView => {
self.tree_mode = !self.tree_mode;
self.selected = 0;
self.scroll_offset = 0;
self.recompute_visible();
}
Command::SortByCpu => {
self.sort_field = SortField::Cpu;
self.recompute_visible();
}
Command::SortByMem => {
self.sort_field = SortField::Mem;
self.recompute_visible();
}
Command::SortByPid => {
self.sort_field = SortField::Pid;
self.recompute_visible();
}
Command::SortByName => {
self.sort_field = SortField::Name;
self.recompute_visible();
}
Command::SortByUser => {
self.sort_field = SortField::User;
self.recompute_visible();
}
Command::ToggleSortOrder => {
self.sort_order = match self.sort_order {
SortOrder::Asc => SortOrder::Desc,
SortOrder::Desc => SortOrder::Asc,
};
self.recompute_visible();
}
Command::CycleSort => {
self.sort_field = next_sort_field(self.sort_field);
self.recompute_visible();
}
Command::SwitchToGeneral => {
self.tab = Tab::General;
}
Command::SwitchToProcesses => {
self.tab = Tab::Processes;
}
Command::OpenFilter => {
self.filter_active = true;
}
Command::NextTab => {
self.tab = self.tab.next();
}
Command::PrevTab => {
self.tab = self.tab.prev();
}
Command::KillProcess => {
if self.tab == Tab::Processes {
self.request_kill(Signal::Term);
}
}
Command::ForceKillProcess => {
if self.tab == Tab::Processes {
self.request_kill(Signal::Kill);
}
}
Command::NiceDown => {
if self.tab == Tab::Processes {
self.request_renice(1);
}
}
Command::NiceUp => {
if self.tab == Tab::Processes {
self.request_renice(-1);
}
}
Command::ClearFilter => match self.tab {
Tab::Network => self.net_filter_input.clear(),
Tab::Containers => self.containers_filter_input.clear(),
_ => {
self.filter_input.clear();
self.recompute_visible();
}
},
Command::SwitchToNetwork => {
self.tab = Tab::Network;
}
Command::SortNetByRx => {
self.net_sort_field = NetworkSortField::RxRate;
}
Command::SortNetByTx => {
self.net_sort_field = NetworkSortField::TxRate;
}
Command::SortNetByName => {
self.net_sort_field = NetworkSortField::Name;
}
Command::SortNetByErrors => {
self.net_sort_field = NetworkSortField::Errors;
}
Command::SwitchToContainers => {
self.tab = Tab::Containers;
}
Command::SortContainersByCpu => {
self.containers_sort_field = ContainerSortField::Cpu;
self.recompute_containers_view();
}
Command::SortContainersByMem => {
self.containers_sort_field = ContainerSortField::Mem;
self.recompute_containers_view();
}
Command::SortContainersByName => {
self.containers_sort_field = ContainerSortField::Name;
self.recompute_containers_view();
}
Command::SortContainersByNetRx => {
self.containers_sort_field = ContainerSortField::NetRx;
self.recompute_containers_view();
}
Command::StopContainer => self.request_container_stop(),
Command::KillContainer => self.request_container_kill(),
Command::RestartContainer => self.request_container_restart(),
Command::SwitchToKube => {
self.tab = Tab::Kube;
}
}
}
const MAX_FILTER_LEN: usize = 256;
fn handle_filter_key(&mut self, key: KeyEvent) {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.quit();
return;
}
if self.net_filter_active {
match key.code {
KeyCode::Esc => {
self.net_filter_active = false;
}
KeyCode::Enter => {
self.net_filter_active = false;
}
KeyCode::Backspace => {
self.net_filter_input.pop();
}
KeyCode::Char(c) if self.net_filter_input.len() < Self::MAX_FILTER_LEN => {
self.net_filter_input.push(c);
}
_ => {}
}
return;
}
if self.containers_filter_active {
match key.code {
KeyCode::Esc | KeyCode::Enter => {
self.containers_filter_active = false;
}
KeyCode::Backspace => {
self.containers_filter_input.pop();
self.recompute_containers_view();
}
KeyCode::Char(c) if self.containers_filter_input.len() < Self::MAX_FILTER_LEN => {
self.containers_filter_input.push(c);
self.recompute_containers_view();
}
_ => {}
}
return;
}
if self.kube_filter_active {
match key.code {
KeyCode::Esc | KeyCode::Enter => {
self.kube_filter_active = false;
}
KeyCode::Backspace => {
self.kube_filter_input.pop();
self.kube_selected = 0;
self.kube_scroll_offset = 0;
}
KeyCode::Char(c) if self.kube_filter_input.len() < Self::MAX_FILTER_LEN => {
self.kube_filter_input.push(c);
self.kube_selected = 0;
self.kube_scroll_offset = 0;
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc => {
self.filter_active = false;
}
KeyCode::Enter => {
self.filter_active = false;
self.last_filter_change = None;
self.recompute_visible();
}
KeyCode::Backspace => {
self.filter_input.pop();
self.recompute_visible_debounced();
}
KeyCode::Char(c) if self.filter_input.len() < Self::MAX_FILTER_LEN => {
self.filter_input.push(c);
self.recompute_visible_debounced();
}
_ => {}
}
}
pub fn handle_mouse_event(&mut self, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollDown => {
let max = self.process_count().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + 1).min(max);
}
MouseEventKind::ScrollUp => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
_ => {}
}
}
}
fn next_sort_field(field: SortField) -> SortField {
match field {
SortField::Cpu => SortField::Mem,
SortField::Mem => SortField::Pid,
SortField::Pid => SortField::Name,
SortField::Name => SortField::User,
SortField::User => SortField::Cpu,
}
}
fn next_net_sort_field(field: NetworkSortField) -> NetworkSortField {
match field {
NetworkSortField::RxRate => NetworkSortField::TxRate,
NetworkSortField::TxRate => NetworkSortField::Name,
NetworkSortField::Name => NetworkSortField::Errors,
NetworkSortField::Errors => NetworkSortField::TotalRx,
NetworkSortField::TotalRx => NetworkSortField::TotalTx,
NetworkSortField::TotalTx => NetworkSortField::RxRate,
}
}
fn next_kube_sort_field(field: KubeSortField, sv: KubeSubview) -> KubeSortField {
match sv {
KubeSubview::Pods => match field {
KubeSortField::PodCpu => KubeSortField::PodMem,
KubeSortField::PodMem => KubeSortField::PodName,
KubeSortField::PodName => KubeSortField::PodRestarts,
KubeSortField::PodRestarts => KubeSortField::PodAge,
KubeSortField::PodAge => KubeSortField::PodPhase,
KubeSortField::PodPhase => KubeSortField::PodCpu,
_ => KubeSortField::default_for(sv),
},
KubeSubview::Nodes => match field {
KubeSortField::NodeCpuPct => KubeSortField::NodeMemPct,
KubeSortField::NodeMemPct => KubeSortField::NodeName,
KubeSortField::NodeName => KubeSortField::NodePodCount,
KubeSortField::NodePodCount => KubeSortField::NodeAge,
KubeSortField::NodeAge => KubeSortField::NodeCpuPct,
_ => KubeSortField::default_for(sv),
},
KubeSubview::Deployments => match field {
KubeSortField::DeployName => KubeSortField::DeployReadyRatio,
KubeSortField::DeployReadyRatio => KubeSortField::DeployNamespace,
KubeSortField::DeployNamespace => KubeSortField::DeployAge,
KubeSortField::DeployAge => KubeSortField::DeployName,
_ => KubeSortField::default_for(sv),
},
}
}
fn next_container_sort_field(field: ContainerSortField) -> ContainerSortField {
match field {
ContainerSortField::Cpu => ContainerSortField::Mem,
ContainerSortField::Mem => ContainerSortField::Name,
ContainerSortField::Name => ContainerSortField::NetRx,
ContainerSortField::NetRx => ContainerSortField::NetTx,
ContainerSortField::NetTx => ContainerSortField::Uptime,
ContainerSortField::Uptime => ContainerSortField::Cpu,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_process(pid: u32, name: &str, cpu: f32, mem: u64) -> ProcessInfo {
ProcessInfo {
pid,
parent_pid: None,
name: name.to_string(),
command: format!("/usr/bin/{name}"),
user: "user".to_string(),
cpu_percent: cpu,
memory_bytes: mem,
memory_percent: 0.0,
status: "Running".to_string(),
}
}
fn make_snapshot(processes: Vec<ProcessInfo>) -> SystemSnapshot {
use muxtop_core::network::NetworkSnapshot;
use muxtop_core::system::{CpuSnapshot, LoadSnapshot, MemorySnapshot};
SystemSnapshot {
cpu: CpuSnapshot {
global_usage: 25.0,
cores: vec![],
},
memory: MemorySnapshot {
total: 16_000_000_000,
used: 8_000_000_000,
available: 8_000_000_000,
swap_total: 0,
swap_used: 0,
},
load: LoadSnapshot {
one: 1.0,
five: 0.8,
fifteen: 0.5,
uptime_secs: 3600,
},
processes,
networks: NetworkSnapshot {
interfaces: vec![],
total_rx: 0,
total_tx: 0,
},
containers: None,
kube: None,
timestamp_ms: 0,
}
}
#[test]
fn test_tab_default_is_general() {
assert_eq!(Tab::default(), Tab::General);
}
#[test]
fn test_tab_next_cycles() {
assert_eq!(Tab::General.next(), Tab::Processes);
assert_eq!(Tab::Processes.next(), Tab::Network);
assert_eq!(Tab::Network.next(), Tab::Containers);
assert_eq!(Tab::Containers.next(), Tab::Kube);
assert_eq!(Tab::Kube.next(), Tab::General);
}
#[test]
fn test_tab_prev_cycles() {
assert_eq!(Tab::Kube.prev(), Tab::Containers);
assert_eq!(Tab::Containers.prev(), Tab::Network);
assert_eq!(Tab::Network.prev(), Tab::Processes);
assert_eq!(Tab::Processes.prev(), Tab::General);
assert_eq!(Tab::General.prev(), Tab::Kube);
}
#[test]
fn test_tab_label_values() {
assert_eq!(Tab::General.label(), "General");
assert_eq!(Tab::Processes.label(), "Processes");
assert_eq!(Tab::Network.label(), "Network");
assert_eq!(Tab::Containers.label(), "Containers");
assert_eq!(Tab::Kube.label(), "Kube");
}
#[test]
fn test_tab_display() {
assert_eq!(format!("{}", Tab::General), "General");
assert_eq!(format!("{}", Tab::Processes), "Processes");
assert_eq!(format!("{}", Tab::Network), "Network");
assert_eq!(format!("{}", Tab::Containers), "Containers");
assert_eq!(format!("{}", Tab::Kube), "Kube");
}
#[test]
fn test_tab_all_contains_all() {
assert!(Tab::ALL.contains(&Tab::General));
assert!(Tab::ALL.contains(&Tab::Processes));
assert!(Tab::ALL.contains(&Tab::Network));
assert!(Tab::ALL.contains(&Tab::Containers));
assert!(Tab::ALL.contains(&Tab::Kube));
assert_eq!(Tab::ALL.len(), 5);
}
#[test]
fn kube_scope_toggle_without_engine_explains_itself() {
let mut app = AppState::new();
app.tab = Tab::Kube;
assert!(app.cluster_engine.is_none());
app.handle_key_event(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT));
let msg = app
.status_message
.as_ref()
.map(|(m, _)| m.clone())
.expect("A on the Kube tab must set a status message");
assert!(
msg.contains("local-only"),
"message does not explain the limitation: {msg}"
);
}
#[test]
fn kube_scope_toggle_is_scoped_to_the_kube_tab() {
let mut app = AppState::new();
app.tab = Tab::Processes;
app.handle_key_event(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT));
assert!(app.status_message.is_none());
}
#[test]
fn test_app_state_defaults() {
let app = AppState::new();
assert_eq!(app.tab, Tab::General);
assert!(matches!(app.sort_field, SortField::Cpu));
assert!(matches!(app.sort_order, SortOrder::Desc));
assert!(app.filter_input.is_empty());
assert!(!app.filter_active);
assert!(!app.tree_mode);
assert_eq!(app.selected, 0);
assert_eq!(app.scroll_offset, 0);
assert!(!app.show_palette);
assert!(app.running());
assert!(app.last_snapshot.is_none());
}
#[test]
fn test_app_state_running_and_quit() {
let mut app = AppState::new();
assert!(app.running());
app.quit();
assert!(!app.running());
}
#[test]
fn test_selected_process_none_initially() {
let app = AppState::new();
assert!(app.selected_process().is_none());
}
#[test]
fn test_apply_snapshot_populates_visible() {
let mut app = AppState::new();
let snap = make_snapshot(vec![
make_process(1, "firefox", 50.0, 1000),
make_process(2, "chrome", 30.0, 2000),
]);
app.apply_snapshot(snap);
assert!(!app.visible_processes.is_empty());
assert!(app.last_snapshot.is_some());
}
#[test]
fn test_apply_snapshot_sorts_cpu_desc() {
let mut app = AppState::new();
app.sort_field = SortField::Cpu;
app.sort_order = SortOrder::Desc;
let snap = make_snapshot(vec![
make_process(1, "low", 10.0, 100),
make_process(2, "high", 90.0, 200),
make_process(3, "mid", 50.0, 300),
]);
app.apply_snapshot(snap);
let cpus: Vec<f32> = app
.visible_processes
.iter()
.map(|p| p.cpu_percent)
.collect();
assert_eq!(cpus, vec![90.0, 50.0, 10.0]);
}
#[test]
fn test_apply_snapshot_filters() {
let mut app = AppState::new();
app.filter_input = "fire".to_string();
let snap = make_snapshot(vec![
make_process(1, "firefox", 50.0, 1000),
make_process(2, "chrome", 30.0, 2000),
make_process(3, "firefox-esr", 20.0, 500),
]);
app.apply_snapshot(snap);
assert_eq!(app.visible_processes.len(), 2);
assert!(
app.visible_processes
.iter()
.all(|p| p.name.contains("fire"))
);
}
#[test]
fn test_apply_snapshot_tree_mode() {
let mut app = AppState::new();
app.tree_mode = true;
let snap = make_snapshot(vec![
make_process(1, "init", 1.0, 100),
make_process(2, "child", 2.0, 200),
]);
app.apply_snapshot(snap);
assert!(!app.visible_tree.is_empty());
}
#[test]
fn test_selected_process_after_snapshot() {
let mut app = AppState::new();
let snap = make_snapshot(vec![make_process(1, "proc", 10.0, 100)]);
app.apply_snapshot(snap);
assert!(app.selected_process().is_some());
}
#[test]
fn test_process_count_flat_vs_tree() {
let mut app = AppState::new();
let snap = make_snapshot(vec![
make_process(1, "a", 10.0, 100),
make_process(2, "b", 20.0, 200),
]);
app.apply_snapshot(snap);
let flat_count = app.process_count();
app.tree_mode = true;
app.recompute_visible();
let tree_count = app.process_count();
assert_eq!(flat_count, 2);
assert_eq!(tree_count, 2);
}
#[test]
fn test_apply_snapshot_clamps_selection() {
let mut app = AppState::new();
app.selected = 10; let snap = make_snapshot(vec![
make_process(1, "a", 10.0, 100),
make_process(2, "b", 20.0, 200),
]);
app.apply_snapshot(snap);
assert!(app.selected < app.process_count());
}
#[test]
fn test_app_state_is_send() {
fn assert_send<T: Send>() {}
assert_send::<AppState>();
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn key_mod(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn app_with_processes() -> AppState {
let mut app = AppState::new();
let snap = make_snapshot(vec![
make_process(1, "alpha", 90.0, 500),
make_process(2, "bravo", 50.0, 300),
make_process(3, "charlie", 30.0, 200),
make_process(4, "delta", 10.0, 100),
make_process(5, "echo", 70.0, 400),
]);
app.apply_snapshot(snap);
app
}
#[test]
fn test_quit_q() {
let mut app = AppState::new();
app.handle_key_event(key(KeyCode::Char('q')));
assert!(!app.running());
}
#[test]
fn test_quit_ctrl_c() {
let mut app = AppState::new();
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(!app.running());
}
#[test]
fn test_navigate_down() {
let mut app = app_with_processes();
assert_eq!(app.selected, 0);
app.handle_key_event(key(KeyCode::Char('j')));
assert_eq!(app.selected, 1);
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.selected, 2);
}
#[test]
fn test_navigate_up() {
let mut app = app_with_processes();
app.selected = 3;
app.handle_key_event(key(KeyCode::Char('k')));
assert_eq!(app.selected, 2);
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.selected, 1);
}
#[test]
fn test_navigate_home() {
let mut app = app_with_processes();
app.selected = 3;
app.handle_key_event(key(KeyCode::Char('g')));
assert_eq!(app.selected, 0);
}
#[test]
fn test_navigate_end() {
let mut app = app_with_processes();
app.handle_key_event(key(KeyCode::Char('G')));
assert_eq!(app.selected, app.process_count() - 1);
}
#[test]
fn test_navigate_clamp_bottom() {
let mut app = app_with_processes();
app.selected = app.process_count() - 1;
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.selected, app.process_count() - 1);
}
#[test]
fn test_navigate_clamp_top() {
let mut app = app_with_processes();
app.selected = 0;
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.selected, 0);
}
#[test]
fn test_navigate_no_snapshot() {
let mut app = AppState::new();
app.handle_key_event(key(KeyCode::Down));
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.selected, 0);
}
#[test]
fn test_tab_switch() {
let mut app = AppState::new();
assert_eq!(app.tab, Tab::General);
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.tab, Tab::Processes);
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.tab, Tab::Network);
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.tab, Tab::Containers);
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.tab, Tab::Kube);
app.handle_key_event(key(KeyCode::Tab));
assert_eq!(app.tab, Tab::General);
}
#[test]
fn test_backtab_switch() {
let mut app = AppState::new();
app.handle_key_event(key(KeyCode::BackTab));
assert_eq!(app.tab, Tab::Kube);
}
#[test]
fn test_tree_toggle() {
let mut app = app_with_processes();
assert!(!app.tree_mode);
app.handle_key_event(key(KeyCode::Char('t')));
assert!(app.tree_mode);
app.handle_key_event(key(KeyCode::Char('t')));
assert!(!app.tree_mode);
}
#[test]
fn test_sort_cycle() {
let mut app = app_with_processes();
assert!(matches!(app.sort_field, SortField::Cpu));
app.handle_key_event(key(KeyCode::Char('s')));
assert!(matches!(app.sort_field, SortField::Mem));
app.handle_key_event(key(KeyCode::Char('s')));
assert!(matches!(app.sort_field, SortField::Pid));
}
#[test]
fn test_sort_order_toggle() {
let mut app = app_with_processes();
assert!(matches!(app.sort_order, SortOrder::Desc));
app.handle_key_event(key(KeyCode::Char('S')));
assert!(matches!(app.sort_order, SortOrder::Asc));
app.handle_key_event(key(KeyCode::Char('S')));
assert!(matches!(app.sort_order, SortOrder::Desc));
}
#[test]
fn test_sort_f_keys() {
let mut app = app_with_processes();
app.handle_key_event(key(KeyCode::F(1)));
assert!(matches!(app.sort_field, SortField::Pid));
app.handle_key_event(key(KeyCode::F(2)));
assert!(matches!(app.sort_field, SortField::Name));
app.handle_key_event(key(KeyCode::F(3)));
assert!(matches!(app.sort_field, SortField::Cpu));
app.handle_key_event(key(KeyCode::F(4)));
assert!(matches!(app.sort_field, SortField::Mem));
app.handle_key_event(key(KeyCode::F(5)));
assert!(matches!(app.sort_field, SortField::User));
}
#[test]
fn test_filter_enter_exit() {
let mut app = AppState::new();
assert!(!app.filter_active);
app.handle_key_event(key(KeyCode::Char('/')));
assert!(app.filter_active);
app.handle_key_event(key(KeyCode::Esc));
assert!(!app.filter_active);
}
#[test]
fn test_filter_typing() {
let mut app = AppState::new();
app.handle_key_event(key(KeyCode::Char('/')));
assert!(app.filter_active);
app.handle_key_event(key(KeyCode::Char('f')));
app.handle_key_event(key(KeyCode::Char('o')));
app.handle_key_event(key(KeyCode::Char('o')));
assert_eq!(app.filter_input, "foo");
}
#[test]
fn test_filter_backspace() {
let mut app = AppState::new();
app.filter_input = "bar".to_string();
app.filter_active = true;
app.handle_key_event(key(KeyCode::Backspace));
assert_eq!(app.filter_input, "ba");
app.handle_key_event(key(KeyCode::Backspace));
assert_eq!(app.filter_input, "b");
}
#[test]
fn test_filter_enter_keeps() {
let mut app = AppState::new();
app.filter_input = "test".to_string();
app.filter_active = true;
app.handle_key_event(key(KeyCode::Enter));
assert!(!app.filter_active);
assert_eq!(app.filter_input, "test"); }
#[test]
fn test_ctrl_c_quits_in_filter_mode() {
let mut app = AppState::new();
app.filter_active = true;
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(!app.running());
}
#[test]
fn test_tab_alt1_switches_to_general() {
let mut app = AppState::new();
app.tab = Tab::Processes;
app.handle_key_event(key_mod(KeyCode::Char('1'), KeyModifiers::ALT));
assert_eq!(app.tab, Tab::General);
}
#[test]
fn test_tab_alt2_switches_to_processes() {
let mut app = AppState::new();
app.handle_key_event(key_mod(KeyCode::Char('2'), KeyModifiers::ALT));
assert_eq!(app.tab, Tab::Processes);
}
#[test]
fn test_tab_alt1_idempotent_on_general() {
let mut app = AppState::new();
assert_eq!(app.tab, Tab::General);
app.handle_key_event(key_mod(KeyCode::Char('1'), KeyModifiers::ALT));
assert_eq!(app.tab, Tab::General);
}
#[test]
fn test_palette_toggle() {
let mut app = AppState::new();
assert!(!app.show_palette);
app.handle_key_event(key_mod(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert!(app.show_palette);
app.handle_key_event(key_mod(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert!(!app.show_palette);
}
#[test]
fn test_tab_right_arrow_next() {
let mut app = AppState::new();
assert_eq!(app.tab, Tab::General);
app.handle_key_event(key(KeyCode::Right));
assert_eq!(app.tab, Tab::Processes);
}
#[test]
fn test_tab_left_arrow_prev() {
let mut app = AppState::new();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.tab, Tab::General);
}
#[test]
fn test_tab_left_arrow_wraps() {
let mut app = AppState::new();
assert_eq!(app.tab, Tab::General);
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.tab, Tab::Kube);
}
#[test]
fn test_tab_right_arrow_wraps() {
let mut app = AppState::new();
app.tab = Tab::Kube;
app.handle_key_event(key(KeyCode::Right));
assert_eq!(app.tab, Tab::General);
}
fn mouse_scroll(kind: MouseEventKind) -> MouseEvent {
MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn test_mouse_scroll_down() {
let mut app = app_with_processes();
assert_eq!(app.scroll_offset, 0);
app.handle_mouse_event(mouse_scroll(MouseEventKind::ScrollDown));
assert_eq!(app.scroll_offset, 1);
}
#[test]
fn test_mouse_scroll_up() {
let mut app = app_with_processes();
app.scroll_offset = 3;
app.handle_mouse_event(mouse_scroll(MouseEventKind::ScrollUp));
assert_eq!(app.scroll_offset, 2);
}
#[test]
fn test_mouse_scroll_clamp_min() {
let mut app = app_with_processes();
app.scroll_offset = 0;
app.handle_mouse_event(mouse_scroll(MouseEventKind::ScrollUp));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn test_mouse_scroll_clamp_max() {
let mut app = app_with_processes();
app.scroll_offset = app.process_count().saturating_sub(1);
app.handle_mouse_event(mouse_scroll(MouseEventKind::ScrollDown));
assert_eq!(app.scroll_offset, app.process_count().saturating_sub(1));
}
#[test]
fn test_command_registry_count() {
assert!(
Command::ALL.len() >= 14,
"Registry should have at least 14 commands, got {}",
Command::ALL.len()
);
}
#[test]
fn test_command_labels_non_empty() {
for cmd in Command::ALL {
assert!(!cmd.label().is_empty(), "Command {:?} has empty label", cmd);
}
}
#[test]
fn test_palette_state_new() {
let ps = PaletteState::new();
assert!(ps.input.is_empty());
assert_eq!(ps.selected, 0);
assert_eq!(ps.filtered.len(), Command::ALL.len());
}
#[test]
fn test_palette_refilter_empty_shows_all() {
let mut ps = PaletteState::new();
ps.refilter();
assert_eq!(ps.filtered.len(), Command::ALL.len());
}
#[test]
fn test_palette_refilter_fuzzy_match() {
let mut ps = PaletteState::new();
ps.input = "sort cpu".to_string();
ps.refilter();
assert!(!ps.filtered.is_empty(), "Should match at least one command");
assert_eq!(
ps.filtered[0].0,
Command::SortByCpu,
"First result should be Sort by CPU"
);
}
#[test]
fn test_palette_refilter_no_match() {
let mut ps = PaletteState::new();
ps.input = "zzzzznonexistent".to_string();
ps.refilter();
assert!(ps.filtered.is_empty(), "Should have no matches");
}
#[test]
fn test_palette_refilter_clamps_selection() {
let mut ps = PaletteState::new();
ps.selected = 100;
ps.input = "quit".to_string();
ps.refilter();
assert!(ps.selected < ps.filtered.len());
}
#[test]
fn test_palette_opens_with_ctrl_p() {
let mut app = AppState::new();
assert!(!app.show_palette);
app.handle_key_event(key_mod(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert!(app.show_palette);
assert!(app.palette.input.is_empty());
}
#[test]
fn test_palette_closes_with_esc() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.input = "test".to_string();
app.handle_key_event(key(KeyCode::Esc));
assert!(!app.show_palette);
assert!(app.palette.input.is_empty());
}
#[test]
fn test_palette_closes_with_ctrl_p() {
let mut app = AppState::new();
app.show_palette = true;
app.handle_key_event(key_mod(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert!(!app.show_palette);
}
#[test]
fn test_palette_typing_captures_input() {
let mut app = AppState::new();
app.show_palette = true;
app.handle_key_event(key(KeyCode::Char('s')));
app.handle_key_event(key(KeyCode::Char('o')));
app.handle_key_event(key(KeyCode::Char('r')));
app.handle_key_event(key(KeyCode::Char('t')));
assert_eq!(app.palette.input, "sort");
}
#[test]
fn test_palette_backspace() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.input = "sor".to_string();
app.handle_key_event(key(KeyCode::Backspace));
assert_eq!(app.palette.input, "so");
}
#[test]
fn test_palette_blocks_quit() {
let mut app = AppState::new();
app.show_palette = true;
app.handle_key_event(key(KeyCode::Char('q')));
assert!(app.running(), "Pressing 'q' in palette should NOT quit");
assert_eq!(app.palette.input, "q", "Should type 'q' into palette");
}
#[test]
fn test_palette_ctrl_c_quits() {
let mut app = AppState::new();
app.show_palette = true;
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(!app.running(), "Ctrl+C should quit even with palette open");
}
#[test]
fn test_palette_navigate_down() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
assert_eq!(app.palette.selected, 0);
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.palette.selected, 1);
}
#[test]
fn test_palette_navigate_up() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
app.palette.selected = 3;
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.palette.selected, 2);
}
#[test]
fn test_palette_navigate_clamp_top() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.selected = 0;
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.palette.selected, 0);
}
#[test]
fn test_palette_navigate_clamp_bottom() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
let max = app.palette.filtered.len() - 1;
app.palette.selected = max;
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.palette.selected, max);
}
#[test]
fn test_palette_execute_quit() {
let mut app = AppState::new();
app.execute_command(Command::Quit);
assert!(!app.running());
}
#[test]
fn test_palette_execute_toggle_tree() {
let mut app = app_with_processes();
assert!(!app.tree_mode);
app.execute_command(Command::ToggleTreeView);
assert!(app.tree_mode);
}
#[test]
fn test_palette_execute_sort_cpu() {
let mut app = app_with_processes();
app.sort_field = SortField::Pid;
app.execute_command(Command::SortByCpu);
assert!(matches!(app.sort_field, SortField::Cpu));
}
#[test]
fn test_palette_execute_sort_mem() {
let mut app = app_with_processes();
app.execute_command(Command::SortByMem);
assert!(matches!(app.sort_field, SortField::Mem));
}
#[test]
fn test_palette_execute_toggle_sort_order() {
let mut app = app_with_processes();
assert!(matches!(app.sort_order, SortOrder::Desc));
app.execute_command(Command::ToggleSortOrder);
assert!(matches!(app.sort_order, SortOrder::Asc));
}
#[test]
fn test_palette_execute_switch_tab() {
let mut app = AppState::new();
app.execute_command(Command::SwitchToProcesses);
assert_eq!(app.tab, Tab::Processes);
app.execute_command(Command::SwitchToGeneral);
assert_eq!(app.tab, Tab::General);
}
#[test]
fn test_palette_execute_open_filter() {
let mut app = AppState::new();
app.execute_command(Command::OpenFilter);
assert!(app.filter_active);
}
#[test]
fn test_palette_enter_executes_and_closes() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
app.palette.selected = 0;
assert_eq!(app.palette.filtered[0].0, Command::Quit);
app.handle_key_event(key(KeyCode::Enter));
assert!(!app.show_palette);
assert!(!app.running());
}
#[test]
fn test_confirm_dialog_opens_on_f9() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_some());
if let Some(ConfirmAction::Kill { signal, .. }) = &app.confirm {
assert_eq!(*signal, Signal::Term);
} else {
panic!("Expected Kill confirm action");
}
}
#[test]
fn test_confirm_dialog_opens_on_f10_sigkill() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(10)));
assert!(app.confirm.is_some());
if let Some(ConfirmAction::Kill { signal, .. }) = &app.confirm {
assert_eq!(*signal, Signal::Kill);
} else {
panic!("Expected Kill confirm with SIGKILL");
}
}
#[test]
fn test_confirm_cancel_with_n() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_some());
app.handle_key_event(key(KeyCode::Char('n')));
assert!(app.confirm.is_none());
}
#[test]
fn test_confirm_cancel_with_esc() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_some());
app.handle_key_event(key(KeyCode::Esc));
assert!(app.confirm.is_none());
}
#[test]
fn test_confirm_blocks_other_keys() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_some());
app.handle_key_event(key(KeyCode::Char('q')));
assert!(app.running());
assert!(app.confirm.is_some());
}
#[test]
fn test_confirm_ctrl_c_quits() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_some());
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(!app.running());
}
#[test]
fn test_kill_no_op_on_general_tab() {
let mut app = app_with_processes();
app.tab = Tab::General;
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_none());
}
#[test]
fn test_kill_no_op_without_process() {
let mut app = AppState::new();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_none());
}
#[test]
fn test_renice_opens_confirm_on_f7() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(7)));
assert!(app.confirm.is_some());
if let Some(ConfirmAction::Renice { delta, .. }) = &app.confirm {
assert_eq!(*delta, 1);
} else {
panic!("Expected Renice confirm action");
}
}
#[test]
fn test_renice_opens_confirm_on_f8() {
let mut app = app_with_processes();
app.tab = Tab::Processes;
app.handle_key_event(key(KeyCode::F(8)));
assert!(app.confirm.is_some());
if let Some(ConfirmAction::Renice { delta, .. }) = &app.confirm {
assert_eq!(*delta, -1);
} else {
panic!("Expected Renice confirm action with delta -1");
}
}
#[test]
fn test_esc_clears_filter() {
let mut app = app_with_processes();
app.filter_input = "firefox".to_string();
app.recompute_visible();
let before = app.visible_processes.len();
app.handle_key_event(key(KeyCode::Esc));
assert!(app.filter_input.is_empty());
assert!(app.visible_processes.len() >= before);
}
#[test]
fn test_esc_no_op_when_filter_empty() {
let mut app = app_with_processes();
assert!(app.filter_input.is_empty());
let count = app.process_count();
app.handle_key_event(key(KeyCode::Esc));
assert_eq!(app.process_count(), count);
}
#[test]
fn test_i_reverses_sort_order() {
let mut app = app_with_processes();
assert!(matches!(app.sort_order, SortOrder::Desc));
app.handle_key_event(key(KeyCode::Char('I')));
assert!(matches!(app.sort_order, SortOrder::Asc));
}
#[test]
fn test_status_message_set_and_read() {
let mut app = AppState::new();
app.set_status("Test message".to_string());
assert_eq!(app.active_status(), Some("Test message"));
}
#[test]
fn test_set_status_scrubs_control_chars() {
let mut app = AppState::new();
app.set_status("Sent SIGTERM to \x1b]0;pwn\x07evil (PID 42)".to_string());
let status = app.active_status().expect("status must be set");
assert!(
!status.contains('\x1b'),
"ESC must not survive set_status: {status:?}"
);
assert!(
!status.contains('\x07'),
"BEL must not survive set_status: {status:?}"
);
assert_eq!(status, "Sent SIGTERM to ?]0;pwn?evil (PID 42)");
}
#[test]
fn test_confirm_prompt_scrubs_control_chars() {
let actions = [
ConfirmAction::Kill {
pid: 42,
name: "bash\x1b]0;pwn\x07".to_string(),
signal: Signal::Term,
},
ConfirmAction::Renice {
pid: 42,
name: "bash\x1b]0;pwn\x07".to_string(),
delta: 1,
},
ConfirmAction::StopContainer {
id: "abc123".to_string(),
name: "nginx\x1b[31m".to_string(),
},
ConfirmAction::KillContainer {
id: "abc123".to_string(),
name: "nginx\x1b[31m".to_string(),
},
ConfirmAction::RestartContainer {
id: "abc123".to_string(),
name: "nginx\x1b[31m".to_string(),
},
];
for action in &actions {
let prompt = action.prompt();
assert!(
!prompt.contains('\x1b'),
"ESC must not survive prompt() for {action:?}: {prompt:?}"
);
assert!(
!prompt.contains('\x07'),
"BEL must not survive prompt() for {action:?}: {prompt:?}"
);
}
}
#[test]
fn test_status_clears_on_key_press() {
let mut app = app_with_processes();
app.set_status("Test message".to_string());
assert!(app.status_message.is_some());
app.handle_key_event(key(KeyCode::Down));
assert!(app.status_message.is_none());
}
#[test]
fn test_command_registry_has_new_commands() {
assert!(
Command::ALL.len() >= 19,
"Registry should have at least 19 commands, got {}",
Command::ALL.len()
);
assert!(Command::ALL.contains(&Command::KillProcess));
assert!(Command::ALL.contains(&Command::ForceKillProcess));
assert!(Command::ALL.contains(&Command::NiceDown));
assert!(Command::ALL.contains(&Command::NiceUp));
assert!(Command::ALL.contains(&Command::ClearFilter));
}
#[test]
fn test_palette_execute_clear_filter() {
let mut app = app_with_processes();
app.filter_input = "test".to_string();
app.execute_command(Command::ClearFilter);
assert!(app.filter_input.is_empty());
}
#[test]
fn test_confirm_prompt_kill_sigterm() {
let action = ConfirmAction::Kill {
pid: 1234,
name: "firefox".to_string(),
signal: Signal::Term,
};
let prompt = action.prompt();
assert!(prompt.contains("SIGTERM"), "Should contain SIGTERM");
assert!(prompt.contains("firefox"), "Should contain process name");
assert!(prompt.contains("1234"), "Should contain PID");
assert!(prompt.contains("[y/n]"), "Should contain y/n prompt");
}
#[test]
fn test_confirm_prompt_kill_sigkill() {
let action = ConfirmAction::Kill {
pid: 999,
name: "chrome".to_string(),
signal: Signal::Kill,
};
let prompt = action.prompt();
assert!(prompt.contains("SIGKILL"), "Should contain SIGKILL");
assert!(prompt.contains("chrome"), "Should contain process name");
assert!(prompt.contains("999"), "Should contain PID");
}
#[test]
fn test_confirm_prompt_renice_down() {
let action = ConfirmAction::Renice {
pid: 42,
name: "bash".to_string(),
delta: 1,
};
let prompt = action.prompt();
assert!(
prompt.contains("lower priority"),
"delta>0 should say 'lower priority'"
);
assert!(prompt.contains("bash"), "Should contain process name");
assert!(prompt.contains("42"), "Should contain PID");
}
#[test]
fn test_confirm_prompt_renice_up() {
let action = ConfirmAction::Renice {
pid: 77,
name: "vim".to_string(),
delta: -1,
};
let prompt = action.prompt();
assert!(
prompt.contains("higher priority"),
"delta<0 should say 'higher priority'"
);
assert!(prompt.contains("vim"), "Should contain process name");
}
#[test]
fn test_next_sort_field_full_cycle() {
let mut field = SortField::Cpu;
field = next_sort_field(field);
assert!(matches!(field, SortField::Mem));
field = next_sort_field(field);
assert!(matches!(field, SortField::Pid));
field = next_sort_field(field);
assert!(matches!(field, SortField::Name));
field = next_sort_field(field);
assert!(matches!(field, SortField::User));
field = next_sort_field(field);
assert!(matches!(field, SortField::Cpu), "Should cycle back to Cpu");
}
#[test]
fn test_next_sort_field_is_deterministic() {
assert!(matches!(next_sort_field(SortField::Cpu), SortField::Mem));
assert!(matches!(next_sort_field(SortField::Cpu), SortField::Mem));
}
#[test]
fn test_with_config_sort_field() {
let config = crate::CliConfig {
sort_field: SortField::Name,
..Default::default()
};
let app = AppState::with_config(config, TermCaps::default());
assert!(matches!(app.sort_field, SortField::Name));
}
#[test]
fn test_with_config_tree_mode() {
let config = crate::CliConfig {
tree_mode: true,
..Default::default()
};
let app = AppState::with_config(config, TermCaps::default());
assert!(app.tree_mode);
}
#[test]
fn test_with_config_filter() {
let config = crate::CliConfig {
filter: Some("rust".to_string()),
..Default::default()
};
let app = AppState::with_config(config, TermCaps::default());
assert_eq!(app.filter_input, "rust");
}
#[test]
fn test_with_config_no_filter() {
let config = crate::CliConfig::default();
let app = AppState::with_config(config, TermCaps::default());
assert!(app.filter_input.is_empty());
}
#[test]
fn test_apply_empty_snapshot() {
let mut app = AppState::new();
let snap = make_snapshot(vec![]);
app.apply_snapshot(snap);
assert!(app.visible_processes.is_empty());
assert_eq!(app.process_count(), 0);
assert!(app.selected_process().is_none());
}
#[test]
fn test_page_down_navigation() {
let mut app = app_with_processes();
app.handle_key_event(key(KeyCode::PageDown));
assert_eq!(app.selected, 4);
}
#[test]
fn test_page_up_from_bottom() {
let mut app = app_with_processes();
app.selected = 4;
app.handle_key_event(key(KeyCode::PageUp));
assert_eq!(app.selected, 0);
}
#[test]
fn test_home_key_navigation() {
let mut app = app_with_processes();
app.selected = 3;
app.handle_key_event(key(KeyCode::Home));
assert_eq!(app.selected, 0);
}
#[test]
fn test_end_key_navigation() {
let mut app = app_with_processes();
app.handle_key_event(key(KeyCode::End));
assert_eq!(app.selected, app.process_count() - 1);
}
#[test]
fn test_alt3_switches_to_network() {
let mut app = AppState::new();
app.handle_key_event(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::ALT));
assert_eq!(app.tab, Tab::Network);
}
#[test]
fn test_alt_n_reaches_every_tab() {
let expected = [
('1', Tab::General),
('2', Tab::Processes),
('3', Tab::Network),
('4', Tab::Containers),
('5', Tab::Kube),
];
assert_eq!(expected.len(), Tab::ALL.len(), "one Alt+N per tab");
for (digit, tab) in expected {
let mut app = AppState::new();
app.handle_key_event(KeyEvent::new(KeyCode::Char(digit), KeyModifiers::ALT));
assert_eq!(app.tab, tab, "Alt+{digit} must select {tab:?}");
}
}
#[test]
fn test_palette_switches_to_kube() {
assert!(Command::ALL.contains(&Command::SwitchToKube));
assert_eq!(Command::SwitchToKube.shortcut(), "Alt+5");
let mut app = AppState::new();
app.execute_command(Command::SwitchToKube);
assert_eq!(app.tab, Tab::Kube);
}
#[test]
fn test_network_history_populated_on_snapshot() {
let mut app = AppState::new();
assert!(app.network_history.is_empty());
let snap = make_snapshot(vec![]);
app.apply_snapshot(snap);
assert_eq!(app.network_history.len(), 1);
}
#[test]
fn test_network_tab_uses_net_selected() {
let mut app = AppState::new();
app.tab = Tab::Network;
let mut snap = make_snapshot(vec![]);
snap.networks.interfaces = vec![
muxtop_core::network::NetworkInterfaceSnapshot {
name: "eth0".to_string(),
bytes_rx: 1000,
bytes_tx: 500,
packets_rx: 10,
packets_tx: 5,
errors_rx: 0,
errors_tx: 0,
mac_address: "00:00:00:00:00:00".to_string(),
is_up: true,
},
muxtop_core::network::NetworkInterfaceSnapshot {
name: "eth1".to_string(),
bytes_rx: 2000,
bytes_tx: 1000,
packets_rx: 20,
packets_tx: 10,
errors_rx: 0,
errors_tx: 0,
mac_address: "00:00:00:00:00:01".to_string(),
is_up: true,
},
];
app.apply_snapshot(snap);
assert_eq!(app.net_selected, 0);
app.handle_key_event(key(KeyCode::Char('j')));
assert_eq!(app.net_selected, 1);
assert_eq!(app.selected, 0);
}
#[test]
fn test_network_sort_cycling() {
let mut app = AppState::new();
app.tab = Tab::Network;
assert!(matches!(app.net_sort_field, NetworkSortField::RxRate));
app.handle_key_event(key(KeyCode::Char('s')));
assert!(matches!(app.net_sort_field, NetworkSortField::TxRate));
app.handle_key_event(key(KeyCode::Char('s')));
assert!(matches!(app.net_sort_field, NetworkSortField::Name));
}
#[test]
fn test_network_filter_activation() {
let mut app = AppState::new();
app.tab = Tab::Network;
assert!(!app.net_filter_active);
app.handle_key_event(key(KeyCode::Char('/')));
assert!(app.net_filter_active);
assert!(!app.filter_active);
}
#[test]
fn test_network_filter_input() {
let mut app = AppState::new();
app.tab = Tab::Network;
app.handle_key_event(key(KeyCode::Char('/')));
assert!(app.net_filter_active);
app.handle_key_event(key(KeyCode::Char('e')));
app.handle_key_event(key(KeyCode::Char('t')));
app.handle_key_event(key(KeyCode::Char('h')));
assert_eq!(app.net_filter_input, "eth");
app.handle_key_event(key(KeyCode::Esc));
assert!(!app.net_filter_active);
assert_eq!(app.net_filter_input, "eth");
}
#[test]
fn test_switch_to_network_command() {
let mut app = AppState::new();
assert_eq!(app.tab, Tab::General);
app.execute_command(Command::SwitchToNetwork);
assert_eq!(app.tab, Tab::Network);
}
#[test]
fn test_sort_net_commands() {
let mut app = AppState::new();
app.execute_command(Command::SortNetByTx);
assert!(matches!(app.net_sort_field, NetworkSortField::TxRate));
app.execute_command(Command::SortNetByName);
assert!(matches!(app.net_sort_field, NetworkSortField::Name));
app.execute_command(Command::SortNetByErrors);
assert!(matches!(app.net_sort_field, NetworkSortField::Errors));
app.execute_command(Command::SortNetByRx);
assert!(matches!(app.net_sort_field, NetworkSortField::RxRate));
}
#[test]
fn test_network_esc_clears_filter() {
let mut app = AppState::new();
app.tab = Tab::Network;
app.net_filter_input = "eth".to_string();
app.handle_key_event(key(KeyCode::Esc));
assert!(app.net_filter_input.is_empty());
}
#[test]
fn test_net_defaults() {
let app = AppState::new();
assert_eq!(app.net_selected, 0);
assert_eq!(app.net_scroll_offset, 0);
assert!(matches!(app.net_sort_field, NetworkSortField::RxRate));
assert!(app.net_filter_input.is_empty());
assert!(!app.net_filter_active);
}
fn make_remote_app() -> AppState {
let addr: std::net::SocketAddr = "10.0.0.1:4242".parse().unwrap();
let config = crate::CliConfig {
connection_mode: crate::ConnectionMode::Remote {
hostname: "prod-01".to_string(),
addr,
},
..Default::default()
};
AppState::with_config(config, TermCaps::default())
}
#[test]
fn test_connection_mode_remote() {
let app = make_remote_app();
assert!(app.is_remote());
assert!(matches!(
app.connection_mode,
crate::ConnectionMode::Remote { .. }
));
}
#[test]
fn test_connection_mode_local_default() {
let app = AppState::new();
assert!(!app.is_remote());
assert!(matches!(app.connection_mode, crate::ConnectionMode::Local));
}
#[test]
fn test_actions_disabled_remote_kill() {
let mut app = make_remote_app();
app.tab = Tab::Processes;
app.handle_key_event(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE));
assert!(
app.active_status()
.is_some_and(|s| s.contains("disabled in remote")),
"expected 'disabled in remote' status, got {:?}",
app.active_status()
);
}
#[test]
fn test_actions_disabled_remote_force_kill() {
let mut app = make_remote_app();
app.tab = Tab::Processes;
app.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
assert!(
app.active_status()
.is_some_and(|s| s.contains("disabled in remote")),
);
}
#[test]
fn test_actions_disabled_remote_nice() {
let mut app = make_remote_app();
app.tab = Tab::Processes;
app.handle_key_event(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
assert!(
app.active_status()
.is_some_and(|s| s.contains("disabled in remote")),
);
}
#[test]
fn test_palette_filters_remote() {
let mut app = make_remote_app();
app.open_palette();
let cmds: Vec<Command> = app.palette.filtered.iter().map(|(c, _)| *c).collect();
assert!(
!cmds.contains(&Command::KillProcess),
"KillProcess should be hidden"
);
assert!(
!cmds.contains(&Command::ForceKillProcess),
"ForceKillProcess should be hidden"
);
assert!(
!cmds.contains(&Command::NiceDown),
"NiceDown should be hidden"
);
assert!(!cmds.contains(&Command::NiceUp), "NiceUp should be hidden");
assert!(cmds.contains(&Command::Quit));
}
#[test]
fn test_palette_includes_all_local() {
let mut app = AppState::new();
app.open_palette();
let cmds: Vec<Command> = app.palette.filtered.iter().map(|(c, _)| *c).collect();
assert!(
cmds.contains(&Command::KillProcess),
"KillProcess should be in local palette"
);
assert!(
cmds.contains(&Command::NiceDown),
"NiceDown should be in local palette"
);
}
fn make_snapshot_with_container(id: &str, name: &str) -> muxtop_core::system::SystemSnapshot {
use muxtop_core::containers::{
ContainerSnapshot, ContainerState, ContainersSnapshot, EngineKind,
};
use muxtop_core::network::NetworkSnapshot;
use muxtop_core::system::{CpuSnapshot, LoadSnapshot, MemorySnapshot, SystemSnapshot};
SystemSnapshot {
cpu: CpuSnapshot {
global_usage: 0.0,
cores: vec![],
},
memory: MemorySnapshot {
total: 0,
used: 0,
available: 0,
swap_total: 0,
swap_used: 0,
},
load: LoadSnapshot {
one: 0.0,
five: 0.0,
fifteen: 0.0,
uptime_secs: 0,
},
processes: vec![],
networks: NetworkSnapshot {
interfaces: vec![],
total_rx: 0,
total_tx: 0,
},
containers: Some(ContainersSnapshot {
engine: EngineKind::Docker,
daemon_up: true,
containers: vec![ContainerSnapshot {
id: id.into(),
id_full: id.into(),
name: name.into(),
image: "nginx:1.27".into(),
state: ContainerState::Running,
status_text: "Up 1 minute".into(),
cpu_pct: 1.5,
mem_used_bytes: 0,
mem_limit_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
block_read_bytes: 0,
block_write_bytes: 0,
started_at_ms: 1_700_000_000_000,
}],
}),
kube: None,
timestamp_ms: 1_700_000_000_000,
}
}
#[test]
fn test_container_stop_opens_confirm() {
let mut app = AppState::new();
app.tab = Tab::Containers;
app.apply_snapshot(make_snapshot_with_container("abc123def456", "my-nginx"));
app.handle_key_event(key(KeyCode::F(9)));
match &app.confirm {
Some(ConfirmAction::StopContainer { id, name }) => {
assert_eq!(id, "abc123def456");
assert_eq!(name, "my-nginx");
}
other => panic!("expected StopContainer confirm dialog, got {other:?}"),
}
}
#[test]
fn test_container_kill_opens_confirm() {
let mut app = AppState::new();
app.tab = Tab::Containers;
app.apply_snapshot(make_snapshot_with_container("abc", "svc"));
app.handle_key_event(key(KeyCode::F(10)));
assert!(matches!(
app.confirm,
Some(ConfirmAction::KillContainer { .. })
));
}
#[test]
fn test_container_restart_opens_confirm() {
let mut app = AppState::new();
app.tab = Tab::Containers;
app.apply_snapshot(make_snapshot_with_container("abc", "svc"));
app.handle_key_event(key(KeyCode::F(11)));
assert!(matches!(
app.confirm,
Some(ConfirmAction::RestartContainer { .. })
));
}
#[test]
fn test_container_actions_disabled_in_remote_mode() {
let mut app = make_remote_app();
app.tab = Tab::Containers;
app.apply_snapshot(make_snapshot_with_container("abc", "svc"));
app.handle_key_event(key(KeyCode::F(9)));
assert!(app.confirm.is_none(), "confirm dialog should not open");
assert!(
app.active_status()
.is_some_and(|s| s.contains("disabled in remote")),
"expected remote-mode notice, got {:?}",
app.active_status()
);
}
#[test]
fn test_container_action_on_other_tabs_ignored() {
let mut app = AppState::new();
app.apply_snapshot(make_snapshot_with_container("abc", "svc"));
for tab in [Tab::General, Tab::Processes, Tab::Network] {
app.tab = tab;
app.confirm = None;
app.handle_key_event(key(KeyCode::F(11)));
assert!(
app.confirm.is_none(),
"F11 on {tab:?} must not open a container confirm"
);
}
}
#[test]
fn test_palette_excludes_container_actions_in_remote() {
let mut app = make_remote_app();
app.open_palette();
let cmds: Vec<Command> = app.palette.filtered.iter().map(|(c, _)| *c).collect();
assert!(!cmds.contains(&Command::StopContainer));
assert!(!cmds.contains(&Command::KillContainer));
assert!(!cmds.contains(&Command::RestartContainer));
}
#[test]
fn test_palette_includes_container_actions_in_local() {
let mut app = AppState::new();
app.open_palette();
let cmds: Vec<Command> = app.palette.filtered.iter().map(|(c, _)| *c).collect();
assert!(cmds.contains(&Command::StopContainer));
assert!(cmds.contains(&Command::KillContainer));
assert!(cmds.contains(&Command::RestartContainer));
}
#[tokio::test]
async fn test_execute_container_without_engine_sets_status() {
let mut app = AppState::new();
app.tab = Tab::Containers;
app.apply_snapshot(make_snapshot_with_container("abc", "svc"));
app.handle_key_event(key(KeyCode::F(9)));
app.handle_key_event(key(KeyCode::Char('y')));
assert!(app.confirm.is_none());
assert!(
app.active_status()
.is_some_and(|s| s.contains("not configured")),
"expected 'not configured' status, got {:?}",
app.active_status()
);
}
#[test]
fn test_confirm_action_prompt_mentions_container_name() {
let prompt = ConfirmAction::StopContainer {
id: "abcdef123456789".into(),
name: "nginx".into(),
}
.prompt();
assert!(prompt.contains("Stop"));
assert!(prompt.contains("nginx"));
assert!(prompt.contains("abcdef123456"));
}
#[test]
fn test_tick_does_not_request_redraw() {
let mut app = AppState::new();
assert!(app.take_needs_redraw());
assert!(!app.take_needs_redraw());
app.apply_snapshot(make_snapshot(vec![]));
assert!(app.take_needs_redraw());
assert!(!app.take_needs_redraw());
}
#[test]
fn test_pump_action_results_marks_dirty() {
let mut app = AppState::new();
let _ = app.take_needs_redraw(); app.action_tx.send("kill ok".into()).unwrap();
app.pump_action_results();
assert!(app.take_needs_redraw(), "action outcome must arm redraw");
assert_eq!(app.active_status(), Some("kill ok"));
}
#[test]
fn test_status_message_just_expired_returns_true_and_clears() {
let mut app = AppState::new();
app.status_message = Some(("old".into(), Instant::now() - Duration::from_secs(60)));
assert!(app.status_message_just_expired());
assert!(app.status_message.is_none());
assert!(!app.status_message_just_expired());
}
#[test]
fn test_apply_snapshot_populates_container_cache() {
let mut app = AppState::new();
assert!(app.sorted_filtered_containers().is_empty());
app.apply_snapshot(make_snapshot_with_container("abc", "svc"));
let rows = app.sorted_filtered_containers();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].name, "svc");
assert_eq!(app.selected_container().unwrap().name, "svc");
}
#[test]
fn test_sort_change_refreshes_container_cache() {
let mut app = AppState::new();
app.tab = Tab::Containers;
let mut snap = make_snapshot_with_container("a-id", "alpha");
if let Some(cs) = snap.containers.as_mut() {
cs.containers[0].cpu_pct = 1.0;
cs.containers[0].mem_used_bytes = 9_000;
cs.containers
.push(muxtop_core::containers::ContainerSnapshot {
id: "b-id".into(),
id_full: "b-id".into(),
name: "bravo".into(),
image: "img".into(),
state: muxtop_core::containers::ContainerState::Running,
status_text: "Up".into(),
cpu_pct: 99.0,
mem_used_bytes: 1_000,
mem_limit_bytes: 0,
net_rx_bytes: 0,
net_tx_bytes: 0,
block_read_bytes: 0,
block_write_bytes: 0,
started_at_ms: 0,
});
}
app.apply_snapshot(snap);
assert_eq!(app.sorted_filtered_containers()[0].name, "bravo");
app.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert_eq!(app.sorted_filtered_containers()[0].name, "alpha");
}
#[test]
fn test_quit_cancels_shutdown_token() {
let mut app = AppState::new();
let token = app.shutdown_token();
assert!(!token.is_cancelled());
app.quit();
assert!(token.is_cancelled());
}
#[test]
fn test_palette_matcher_is_cached() {
let mut ps = PaletteState::new();
assert!(ps.matcher.is_none());
ps.input = "sort".into();
ps.refilter();
assert!(
ps.matcher.is_some(),
"matcher should be cached after first use"
);
ps.refilter();
assert!(!ps.filtered.is_empty());
}
#[test]
fn test_filter_debounce_coalesces_bursts() {
let mut app = AppState::new();
app.apply_snapshot(make_snapshot(vec![
make_process(1, "firefox", 10.0, 100),
make_process(2, "kitty", 20.0, 200),
make_process(3, "tmux", 5.0, 50),
]));
app.filter_input.push('f');
app.recompute_visible_debounced();
assert_eq!(app.visible_processes.len(), 1);
assert_eq!(app.visible_processes[0].name, "firefox");
app.filter_input.push('k');
app.recompute_visible_debounced();
assert_eq!(
app.visible_processes.len(),
1,
"burst should be coalesced — the in-flight `fk` filter has NOT been applied yet"
);
app.last_filter_change = None;
app.recompute_visible();
assert_eq!(
app.visible_processes.len(),
0,
"after debounce flush the `fk` filter matches nothing"
);
}
}