Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f238cdf25 | |||
| ffe451edaa | |||
| c9bde52cb1 | |||
| 0603746d7c | |||
| 25632f3427 | |||
| e51cdb0c50 | |||
| 1cb05d404b | |||
| 4196066e57 | |||
| 47e96c7d92 | |||
| bae2ecb79a | |||
| bd0d15a1ae | |||
| 689498c5f4 | |||
| 34e260a612 |
+65
-18
@@ -29,7 +29,8 @@ use crate::ui::cpu::{
|
||||
};
|
||||
use crate::ui::modal::{ModalAction, ModalManager, ModalType};
|
||||
use crate::ui::processes::{
|
||||
ProcSortBy, processes_handle_key_with_selection, processes_handle_mouse_with_selection,
|
||||
ProcSortBy, ProcessKeyParams, processes_handle_key_with_selection,
|
||||
processes_handle_mouse_with_selection,
|
||||
};
|
||||
use crate::ui::{
|
||||
disks::draw_disks, gpu::draw_gpu, header::draw_header, mem::draw_mem, net::draw_net_spark,
|
||||
@@ -614,7 +615,8 @@ impl App {
|
||||
{
|
||||
self.clear_process_details();
|
||||
}
|
||||
// Modal was dismissed, continue to normal processing
|
||||
// Modal was dismissed, skip normal key processing
|
||||
continue;
|
||||
}
|
||||
ModalAction::Confirm => {
|
||||
// Handle confirmation action here if needed in the future
|
||||
@@ -654,6 +656,17 @@ impl App {
|
||||
) {
|
||||
self.should_quit = true;
|
||||
}
|
||||
|
||||
// Show About modal on 'a' or 'A'
|
||||
if matches!(k.code, KeyCode::Char('a') | KeyCode::Char('A')) {
|
||||
self.modal_manager.push_modal(ModalType::About);
|
||||
}
|
||||
|
||||
// Show Help modal on 'h' or 'H'
|
||||
if matches!(k.code, KeyCode::Char('h') | KeyCode::Char('H')) {
|
||||
self.modal_manager.push_modal(ModalType::Help);
|
||||
}
|
||||
|
||||
// Per-core scroll via keys (Up/Down/PageUp/PageDown/Home/End)
|
||||
let sz = terminal.size()?;
|
||||
let area = Rect::new(0, 0, sz.width, sz.height);
|
||||
@@ -674,22 +687,14 @@ impl App {
|
||||
let content = per_core_content_area(top[1]);
|
||||
|
||||
// First try process selection (only handles arrows if a process is selected)
|
||||
let process_handled = if let Some(p_area) = self.last_procs_area {
|
||||
let page = p_area.height.saturating_sub(3).max(1) as usize; // borders (2) + header (1)
|
||||
let total_rows = self
|
||||
.last_metrics
|
||||
.as_ref()
|
||||
.map(|m| m.top_processes.len())
|
||||
.unwrap_or(0);
|
||||
processes_handle_key_with_selection(
|
||||
&mut self.procs_scroll_offset,
|
||||
&mut self.selected_process_pid,
|
||||
&mut self.selected_process_index,
|
||||
k,
|
||||
page,
|
||||
total_rows,
|
||||
self.last_metrics.as_ref(),
|
||||
)
|
||||
let process_handled = if self.last_procs_area.is_some() {
|
||||
processes_handle_key_with_selection(ProcessKeyParams {
|
||||
selected_process_pid: &mut self.selected_process_pid,
|
||||
selected_process_index: &mut self.selected_process_index,
|
||||
key: k,
|
||||
metrics: self.last_metrics.as_ref(),
|
||||
sort_by: self.procs_sort_by,
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -703,6 +708,48 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-scroll to keep selected process visible
|
||||
if let (Some(selected_idx), Some(p_area)) =
|
||||
(self.selected_process_index, self.last_procs_area)
|
||||
{
|
||||
// Calculate viewport size (excluding borders and header)
|
||||
let viewport_rows = p_area.height.saturating_sub(3) as usize; // borders (2) + header (1)
|
||||
|
||||
// Build sorted index list to find display position
|
||||
if let Some(m) = self.last_metrics.as_ref() {
|
||||
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect();
|
||||
match self.procs_sort_by {
|
||||
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].cpu_usage;
|
||||
let bb = m.top_processes[b].cpu_usage;
|
||||
bb.partial_cmp(&aa).unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].mem_bytes;
|
||||
let bb = m.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
|
||||
// Find the display position of the selected process
|
||||
if let Some(display_pos) =
|
||||
idxs.iter().position(|&idx| idx == selected_idx)
|
||||
{
|
||||
// Adjust scroll offset to keep selection visible
|
||||
if display_pos < self.procs_scroll_offset {
|
||||
// Selection is above viewport, scroll up
|
||||
self.procs_scroll_offset = display_pos;
|
||||
} else if display_pos
|
||||
>= self.procs_scroll_offset + viewport_rows
|
||||
{
|
||||
// Selection is below viewport, scroll down
|
||||
self.procs_scroll_offset =
|
||||
display_pos.saturating_sub(viewport_rows - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if process selection changed and clear details if so
|
||||
if self.selected_process_pid != self.prev_selected_process_pid {
|
||||
self.clear_process_details();
|
||||
|
||||
@@ -42,8 +42,8 @@ pub fn per_core_content_area(area: Rect) -> Rect {
|
||||
/// Handles key events for per-core CPU bars.
|
||||
pub fn per_core_handle_key(scroll_offset: &mut usize, key: KeyEvent, page_size: usize) {
|
||||
match key.code {
|
||||
KeyCode::Up => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Down => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::Left => *scroll_offset = scroll_offset.saturating_sub(1),
|
||||
KeyCode::Right => *scroll_offset = scroll_offset.saturating_add(1),
|
||||
KeyCode::PageUp => {
|
||||
let step = page_size.max(1);
|
||||
*scroll_offset = scroll_offset.saturating_sub(step);
|
||||
|
||||
+36
-8
@@ -24,8 +24,16 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter duplicates by keeping first occurrence of each unique name
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
let unique_disks: Vec<_> = mm
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|d| seen_names.insert(d.name.clone()))
|
||||
.collect();
|
||||
|
||||
let per_disk_h = 3u16;
|
||||
let max_cards = (inner.height / per_disk_h).min(mm.disks.len() as u16) as usize;
|
||||
let max_cards = (inner.height / per_disk_h).min(unique_disks.len() as u16) as usize;
|
||||
|
||||
let constraints: Vec<Constraint> = (0..max_cards)
|
||||
.map(|_| Constraint::Length(per_disk_h))
|
||||
@@ -36,7 +44,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
.split(inner);
|
||||
|
||||
for (i, slot) in rows.iter().enumerate() {
|
||||
let d = &mm.disks[i];
|
||||
let d = unique_disks[i];
|
||||
let used = d.total.saturating_sub(d.available);
|
||||
let ratio = if d.total > 0 {
|
||||
used as f64 / d.total as f64
|
||||
@@ -53,23 +61,43 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
ratatui::style::Color::Red
|
||||
};
|
||||
|
||||
// Add indentation for partitions
|
||||
let indent = if d.is_partition { "└─" } else { "" };
|
||||
|
||||
// Add temperature if available
|
||||
let temp_str = d
|
||||
.temperature
|
||||
.map(|t| format!(" {}°C", t.round() as i32))
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = format!(
|
||||
"{} {} {} / {} ({}%)",
|
||||
"{}{}{}{} {} / {} ({}%)",
|
||||
indent,
|
||||
disk_icon(&d.name),
|
||||
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
|
||||
temp_str,
|
||||
human(used),
|
||||
human(d.total),
|
||||
pct
|
||||
);
|
||||
|
||||
// Indent the entire card (block) for partitions to align with └─ prefix (4 chars)
|
||||
let card_indent = if d.is_partition { 4 } else { 0 };
|
||||
let card_rect = Rect {
|
||||
x: slot.x + card_indent,
|
||||
y: slot.y,
|
||||
width: slot.width.saturating_sub(card_indent),
|
||||
height: slot.height,
|
||||
};
|
||||
|
||||
let card = Block::default().borders(Borders::ALL).title(title);
|
||||
f.render_widget(card, *slot);
|
||||
f.render_widget(card, card_rect);
|
||||
|
||||
let inner_card = Rect {
|
||||
x: slot.x + 1,
|
||||
y: slot.y + 1,
|
||||
width: slot.width.saturating_sub(2),
|
||||
height: slot.height.saturating_sub(2),
|
||||
x: card_rect.x + 1,
|
||||
y: card_rect.y + 1,
|
||||
width: card_rect.width.saturating_sub(2),
|
||||
height: card_rect.height.saturating_sub(2),
|
||||
};
|
||||
if inner_card.height == 0 {
|
||||
continue;
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn draw_header(
|
||||
parts.push(tok_txt.into());
|
||||
}
|
||||
parts.push(intervals);
|
||||
parts.push("(q to quit)".into());
|
||||
parts.push("(a: about, h: help, q: quit)".into());
|
||||
let title = parts.join(" | ");
|
||||
f.render_widget(Block::default().title(title).borders(Borders::BOTTOM), area);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ impl ModalManager {
|
||||
self.journal_scroll_max = 0;
|
||||
ModalButton::Ok
|
||||
}
|
||||
Some(ModalType::About) => ModalButton::Ok,
|
||||
Some(ModalType::Help) => ModalButton::Ok,
|
||||
Some(ModalType::Confirmation { .. }) => ModalButton::Confirm,
|
||||
Some(ModalType::Info { .. }) => ModalButton::Ok,
|
||||
None => ModalButton::Ok,
|
||||
@@ -66,6 +68,8 @@ impl ModalManager {
|
||||
self.active_button = match next {
|
||||
ModalType::ConnectionError { .. } => ModalButton::Retry,
|
||||
ModalType::ProcessDetails { .. } => ModalButton::Ok,
|
||||
ModalType::About => ModalButton::Ok,
|
||||
ModalType::Help => ModalButton::Ok,
|
||||
ModalType::Confirmation { .. } => ModalButton::Confirm,
|
||||
ModalType::Info { .. } => ModalButton::Ok,
|
||||
};
|
||||
@@ -205,6 +209,14 @@ impl ModalManager {
|
||||
self.pop_modal();
|
||||
ModalAction::Dismiss
|
||||
}
|
||||
(Some(ModalType::About), ModalButton::Ok) => {
|
||||
self.pop_modal();
|
||||
ModalAction::Dismiss
|
||||
}
|
||||
(Some(ModalType::Help), ModalButton::Ok) => {
|
||||
self.pop_modal();
|
||||
ModalAction::Dismiss
|
||||
}
|
||||
(Some(ModalType::Confirmation { .. }), ModalButton::Confirm) => ModalAction::Confirm,
|
||||
(Some(ModalType::Confirmation { .. }), ModalButton::Cancel) => ModalAction::Cancel,
|
||||
(Some(ModalType::Info { .. }), ModalButton::Ok) => {
|
||||
@@ -253,6 +265,14 @@ impl ModalManager {
|
||||
// Process details modal uses almost full screen (95% width, 90% height)
|
||||
self.centered_rect(95, 90, area)
|
||||
}
|
||||
ModalType::About => {
|
||||
// About modal uses medium size
|
||||
self.centered_rect(90, 90, area)
|
||||
}
|
||||
ModalType::Help => {
|
||||
// Help modal uses medium size
|
||||
self.centered_rect(80, 80, area)
|
||||
}
|
||||
_ => {
|
||||
// Other modals use smaller size
|
||||
self.centered_rect(70, 50, area)
|
||||
@@ -276,6 +296,8 @@ impl ModalManager {
|
||||
ModalType::ProcessDetails { pid } => {
|
||||
self.render_process_details(f, modal_area, *pid, data)
|
||||
}
|
||||
ModalType::About => self.render_about(f, modal_area),
|
||||
ModalType::Help => self.render_help(f, modal_area),
|
||||
ModalType::Confirmation {
|
||||
title,
|
||||
message,
|
||||
@@ -378,6 +400,150 @@ impl ModalManager {
|
||||
);
|
||||
}
|
||||
|
||||
fn render_about(&self, f: &mut Frame, area: Rect) {
|
||||
//get ASCII art from a constant stored in theme.rs
|
||||
use super::theme::ASCII_ART;
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let about_text = format!(
|
||||
"{}\n\
|
||||
Version {}\n\
|
||||
\n\
|
||||
A terminal first remote monitoring tool\n\
|
||||
\n\
|
||||
Website: https://socktop.io\n\
|
||||
GitHub: https://github.com/jasonwitty/socktop\n\
|
||||
\n\
|
||||
License: MIT License\n\
|
||||
\n\
|
||||
Created by Jason Witty\n\
|
||||
jasonpwitty+socktop@proton.me",
|
||||
ASCII_ART, version
|
||||
);
|
||||
|
||||
// Render the border block
|
||||
let block = Block::default()
|
||||
.title(" About socktop ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Calculate inner area manually to avoid any parent styling
|
||||
let inner_area = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2), // Leave room for button at bottom
|
||||
};
|
||||
|
||||
// Render content area with explicit black background
|
||||
f.render_widget(
|
||||
Paragraph::new(about_text)
|
||||
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner_area,
|
||||
);
|
||||
|
||||
// Button area
|
||||
let button_area = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + area.height.saturating_sub(2),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let ok_style = if self.active_button == ModalButton::Ok {
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Blue).bg(Color::Black)
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("[ Enter ] Close")
|
||||
.style(ok_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_help(&self, f: &mut Frame, area: Rect) {
|
||||
let help_text = "\
|
||||
GLOBAL
|
||||
q/Q/Esc ........ Quit │ a/A ....... About │ h/H ....... Help
|
||||
|
||||
PROCESS LIST
|
||||
↑/↓ ............ Select/navigate processes
|
||||
Enter .......... Open Process Details
|
||||
x/X ............ Clear selection
|
||||
Click header ... Sort by column (CPU/Mem)
|
||||
Click row ...... Select process
|
||||
|
||||
CPU PER-CORE
|
||||
←/→ ............ Scroll cores │ PgUp/PgDn ... Page up/down
|
||||
Home/End ....... Jump to first/last core
|
||||
|
||||
PROCESS DETAILS MODAL
|
||||
x/X ............ Close modal (all parent modals)
|
||||
p/P ............ Navigate to parent process
|
||||
j/k ............ Scroll threads ↓/↑ (1 line)
|
||||
d/u ............ Scroll threads ↓/↑ (10 lines)
|
||||
[ / ] .......... Scroll journal ↑/↓
|
||||
Esc/Enter ...... Close modal";
|
||||
|
||||
// Render the border block
|
||||
let block = Block::default()
|
||||
.title(" Hotkey Help ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(Color::Black).fg(Color::DarkGray));
|
||||
f.render_widget(block, area);
|
||||
|
||||
// Calculate inner area manually to avoid any parent styling
|
||||
let inner_area = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2), // Leave room for button at bottom
|
||||
};
|
||||
|
||||
// Render content area with explicit black background
|
||||
f.render_widget(
|
||||
Paragraph::new(help_text)
|
||||
.style(Style::default().fg(Color::Cyan).bg(Color::Black))
|
||||
.alignment(Alignment::Left)
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner_area,
|
||||
);
|
||||
|
||||
// Button area
|
||||
let button_area = Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + area.height.saturating_sub(2),
|
||||
width: area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let ok_style = if self.active_button == ModalButton::Ok {
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Blue).bg(Color::Black)
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new("[ Enter ] Close")
|
||||
.style(ok_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
|
||||
@@ -38,6 +38,8 @@ pub enum ModalType {
|
||||
ProcessDetails {
|
||||
pid: u32,
|
||||
},
|
||||
About,
|
||||
Help,
|
||||
#[allow(dead_code)]
|
||||
Confirmation {
|
||||
title: String,
|
||||
|
||||
+93
-15
@@ -254,6 +254,15 @@ fn fmt_cpu_pct(v: f32) -> String {
|
||||
}
|
||||
|
||||
/// Handle keyboard scrolling (Up/Down/PageUp/PageDown/Home/End)
|
||||
/// Parameters for process key event handling
|
||||
pub struct ProcessKeyParams<'a> {
|
||||
pub selected_process_pid: &'a mut Option<u32>,
|
||||
pub selected_process_index: &'a mut Option<usize>,
|
||||
pub key: crossterm::event::KeyEvent,
|
||||
pub metrics: Option<&'a Metrics>,
|
||||
pub sort_by: ProcSortBy,
|
||||
}
|
||||
|
||||
/// LEGACY: Use processes_handle_key_with_selection for enhanced functionality
|
||||
#[allow(dead_code)]
|
||||
pub fn processes_handle_key(
|
||||
@@ -264,24 +273,93 @@ pub fn processes_handle_key(
|
||||
crate::ui::cpu::per_core_handle_key(scroll_offset, key, page_size);
|
||||
}
|
||||
|
||||
/// Enhanced keyboard handler that also manages process selection
|
||||
pub fn processes_handle_key_with_selection(
|
||||
_scroll_offset: &mut usize,
|
||||
selected_process_pid: &mut Option<u32>,
|
||||
selected_process_index: &mut Option<usize>,
|
||||
key: crossterm::event::KeyEvent,
|
||||
_page_size: usize,
|
||||
_total_rows: usize,
|
||||
_metrics: Option<&Metrics>,
|
||||
) -> bool {
|
||||
pub fn processes_handle_key_with_selection(params: ProcessKeyParams) -> bool {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
match key.code {
|
||||
match params.key.code {
|
||||
KeyCode::Up => {
|
||||
// Build sorted index list to navigate through display order
|
||||
if let Some(m) = params.metrics {
|
||||
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect();
|
||||
match params.sort_by {
|
||||
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].cpu_usage;
|
||||
let bb = m.top_processes[b].cpu_usage;
|
||||
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
|
||||
}),
|
||||
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].mem_bytes;
|
||||
let bb = m.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
|
||||
if params.selected_process_index.is_none() || params.selected_process_pid.is_none()
|
||||
{
|
||||
// No selection - select the first process in sorted order
|
||||
if !idxs.is_empty() {
|
||||
let first_idx = idxs[0];
|
||||
*params.selected_process_index = Some(first_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
||||
}
|
||||
} else if let Some(current_idx) = *params.selected_process_index {
|
||||
// Find current position in sorted list
|
||||
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx)
|
||||
&& pos > 0
|
||||
{
|
||||
// Move up in sorted list
|
||||
let new_idx = idxs[pos - 1];
|
||||
*params.selected_process_index = Some(new_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
true // Handled
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Build sorted index list to navigate through display order
|
||||
if let Some(m) = params.metrics {
|
||||
let mut idxs: Vec<usize> = (0..m.top_processes.len()).collect();
|
||||
match params.sort_by {
|
||||
ProcSortBy::CpuDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].cpu_usage;
|
||||
let bb = m.top_processes[b].cpu_usage;
|
||||
bb.partial_cmp(&aa).unwrap_or(Ordering::Equal)
|
||||
}),
|
||||
ProcSortBy::MemDesc => idxs.sort_by(|&a, &b| {
|
||||
let aa = m.top_processes[a].mem_bytes;
|
||||
let bb = m.top_processes[b].mem_bytes;
|
||||
bb.cmp(&aa)
|
||||
}),
|
||||
}
|
||||
|
||||
if params.selected_process_index.is_none() || params.selected_process_pid.is_none()
|
||||
{
|
||||
// No selection - select the first process in sorted order
|
||||
if !idxs.is_empty() {
|
||||
let first_idx = idxs[0];
|
||||
*params.selected_process_index = Some(first_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[first_idx].pid);
|
||||
}
|
||||
} else if let Some(current_idx) = *params.selected_process_index {
|
||||
// Find current position in sorted list
|
||||
if let Some(pos) = idxs.iter().position(|&idx| idx == current_idx)
|
||||
&& pos + 1 < idxs.len()
|
||||
{
|
||||
// Move down in sorted list
|
||||
let new_idx = idxs[pos + 1];
|
||||
*params.selected_process_index = Some(new_idx);
|
||||
*params.selected_process_pid = Some(m.top_processes[new_idx].pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
true // Handled
|
||||
}
|
||||
KeyCode::Char('x') | KeyCode::Char('X') => {
|
||||
// Unselect any selected process
|
||||
if selected_process_pid.is_some() || selected_process_index.is_some() {
|
||||
*selected_process_pid = None;
|
||||
*selected_process_index = None;
|
||||
if params.selected_process_pid.is_some() || params.selected_process_index.is_some() {
|
||||
*params.selected_process_pid = None;
|
||||
*params.selected_process_index = None;
|
||||
true // Handled
|
||||
} else {
|
||||
false // No selection to clear
|
||||
@@ -289,7 +367,7 @@ pub fn processes_handle_key_with_selection(
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Signal that Enter was pressed with a selection
|
||||
selected_process_pid.is_some() // Return true if we have a selection to handle
|
||||
params.selected_process_pid.is_some() // Return true if we have a selection to handle
|
||||
}
|
||||
_ => {
|
||||
// No other keys handled - let scrollbar handle all navigation
|
||||
|
||||
+27
-1
@@ -49,7 +49,7 @@ pub const ICON_COUNTDOWN_LABEL: &str = "⏰ Next auto retry: ";
|
||||
pub const BTN_RETRY_TEXT: &str = " 🔄 Retry ";
|
||||
pub const BTN_EXIT_TEXT: &str = " ❌ Exit ";
|
||||
|
||||
// Large multi-line warning icon
|
||||
// warning icon
|
||||
pub const LARGE_ERROR_ICON: &[&str] = &[
|
||||
" /\\ ",
|
||||
" / \\ ",
|
||||
@@ -60,3 +60,29 @@ pub const LARGE_ERROR_ICON: &[&str] = &[
|
||||
" / !! \\ ",
|
||||
" /______________\\ ",
|
||||
];
|
||||
|
||||
//about logo
|
||||
pub const ASCII_ART: &str = r#"
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣶⣾⠿⠿⠛⠃⠀⠀⠀⠀⠀⣀⣀⣠⡄⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⢉⣠⣴⣾⣿⠿⠆⢰⣾⡿⠿⠛⠛⠋⠁⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⠟⠋⣁⣤⣤⣶⠀⣠⣤⣶⣾⣿⣿⡿⠀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⣿⣿⣿⣿⣿⡆⠘⠛⢉⣁⣤⣤⣤⡀⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡀⢾⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⣷⠀⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣧⠈⢿⣿⣿⣿⣿⡄⠀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⠿⠋⣁⠀⢿⣿⣿⣿⣷⡀⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⡟⢁⣴⣿⣿⡇⢸⣿⣿⡿⠟⠃⠀⠀
|
||||
⠀⠀⠀⠀⠀⠀⢀⣠⣴⣿⣿⣿⣿⣿⣿⡟⢀⣿⣿⣿⡟⢀⣾⠟⢁⣤⣶⣿⠀⠀
|
||||
⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡿⠟⢋⣠⣾⠃⣰⣿⣿⣿⡟⠀⠀
|
||||
⠀⠀⣴⣄⠙⣿⣿⣿⣿⣿⡿⠿⠛⠋⣉⣁⣤⣴⣶⣿⣿⣿⠀⣿⡿⠟⠋⠀⠀⠀
|
||||
⠀⠀⣿⣿⡆⠹⠟⠋⣁⣤⡄⢰⣿⠿⠟⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
⠀⠀⠈⠉⠁⠀⠀⠀⠙⠛⠃⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
|
||||
███████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗
|
||||
██╔════╝██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗
|
||||
███████╗██║ ██║██║ ██║ ██║ ██║██████╔╝
|
||||
╚════██║██║ ██║██║ ██║ ██║ ██║██╔═══╝
|
||||
███████║╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║
|
||||
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝
|
||||
"#;
|
||||
|
||||
@@ -331,14 +331,194 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
}
|
||||
let mut disks_list = state.disks.lock().await;
|
||||
disks_list.refresh(false); // don't drop missing disks
|
||||
let disks: Vec<DiskInfo> = disks_list
|
||||
|
||||
// Collect disk temperatures from components
|
||||
// NVMe temps show up as "Composite" under different chip names
|
||||
let disk_temps = {
|
||||
let mut components = state.components.lock().await;
|
||||
components.refresh(true); // true = refresh values, not just the list
|
||||
|
||||
let mut composite_temps = Vec::new();
|
||||
|
||||
for c in components.iter() {
|
||||
let label = c.label().to_ascii_lowercase();
|
||||
|
||||
// Collect all "Composite" temperatures (these are NVMe drives)
|
||||
// Labels are like "nvme Composite CT1000N7BSS503" or "nvme Composite Sabrent Rocket 4.0"
|
||||
if label.contains("composite")
|
||||
&& let Some(temp) = c.temperature()
|
||||
{
|
||||
tracing::debug!("Found Composite temp: {}°C", temp);
|
||||
composite_temps.push(temp);
|
||||
}
|
||||
}
|
||||
|
||||
// Store composite temps indexed by their order (nvme0n1, nvme1n1, nvme2n1, etc.)
|
||||
let mut temps = std::collections::HashMap::new();
|
||||
for (idx, temp) in composite_temps.iter().enumerate() {
|
||||
let key = format!("nvme{}n1", idx);
|
||||
tracing::debug!("Mapping {} -> {}°C", key, temp);
|
||||
temps.insert(key, *temp);
|
||||
}
|
||||
tracing::debug!("Final disk_temps map: {:?}", temps);
|
||||
temps
|
||||
};
|
||||
|
||||
// First collect all partitions from sysinfo, deduplicating by device name
|
||||
// (same partition can be mounted at multiple mount points)
|
||||
let mut seen_partitions = std::collections::HashSet::new();
|
||||
let partitions: Vec<DiskInfo> = disks_list
|
||||
.iter()
|
||||
.map(|d| DiskInfo {
|
||||
name: d.name().to_string_lossy().into_owned(),
|
||||
total: d.total_space(),
|
||||
available: d.available_space(),
|
||||
.filter_map(|d| {
|
||||
let name = d.name().to_string_lossy().into_owned();
|
||||
|
||||
// Skip if we've already seen this partition/device
|
||||
if !seen_partitions.insert(name.clone()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Determine if this is a partition
|
||||
let is_partition = name.contains("p1")
|
||||
|| name.contains("p2")
|
||||
|| name.contains("p3")
|
||||
|| name.ends_with('1')
|
||||
|| name.ends_with('2')
|
||||
|| name.ends_with('3')
|
||||
|| name.ends_with('4')
|
||||
|| name.ends_with('5')
|
||||
|| name.ends_with('6')
|
||||
|| name.ends_with('7')
|
||||
|| name.ends_with('8')
|
||||
|| name.ends_with('9');
|
||||
|
||||
// Try to find temperature for this disk
|
||||
let temperature = disk_temps.iter().find_map(|(key, &temp)| {
|
||||
if name.starts_with(key) {
|
||||
tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp);
|
||||
Some(temp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") {
|
||||
tracing::debug!("No temperature found for disk: {}", name);
|
||||
}
|
||||
|
||||
Some(DiskInfo {
|
||||
name,
|
||||
total: d.total_space(),
|
||||
available: d.available_space(),
|
||||
temperature,
|
||||
is_partition,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Now create parent disk entries by aggregating partition data
|
||||
let mut parent_disks: std::collections::HashMap<String, (u64, u64, Option<f32>)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for partition in &partitions {
|
||||
if partition.is_partition {
|
||||
// Extract parent disk name
|
||||
// nvme0n1p1 -> nvme0n1, sda1 -> sda, mmcblk0p1 -> mmcblk0
|
||||
let parent_name = if let Some(pos) = partition.name.rfind('p') {
|
||||
// Check if character after 'p' is a digit
|
||||
if partition
|
||||
.name
|
||||
.chars()
|
||||
.nth(pos + 1)
|
||||
.is_some_and(|c| c.is_ascii_digit())
|
||||
{
|
||||
&partition.name[..pos]
|
||||
} else {
|
||||
// Handle sda1, sdb2, etc (just trim trailing digit)
|
||||
partition.name.trim_end_matches(char::is_numeric)
|
||||
}
|
||||
} else {
|
||||
// Handle sda1, sdb2, etc (just trim trailing digit)
|
||||
partition.name.trim_end_matches(char::is_numeric)
|
||||
};
|
||||
|
||||
// Look up temperature for the PARENT disk, not the partition
|
||||
// Strip /dev/ prefix if present for matching
|
||||
let parent_name_for_match = parent_name.strip_prefix("/dev/").unwrap_or(parent_name);
|
||||
let parent_temp = disk_temps.iter().find_map(|(key, &temp)| {
|
||||
if parent_name_for_match.starts_with(key) {
|
||||
Some(temp)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate partition stats into parent
|
||||
let entry = parent_disks
|
||||
.entry(parent_name.to_string())
|
||||
.or_insert((0, 0, parent_temp));
|
||||
entry.0 += partition.total;
|
||||
entry.1 += partition.available;
|
||||
// Keep temperature if any partition has it (or if we just found one)
|
||||
if entry.2.is_none() {
|
||||
entry.2 = parent_temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create parent disk entries
|
||||
let mut disks: Vec<DiskInfo> = parent_disks
|
||||
.into_iter()
|
||||
.map(|(name, (total, available, temperature))| DiskInfo {
|
||||
name,
|
||||
total,
|
||||
available,
|
||||
temperature,
|
||||
is_partition: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort parent disks by name
|
||||
disks.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
// Add partitions after their parent disk
|
||||
for partition in partitions {
|
||||
if partition.is_partition {
|
||||
// Find parent disk index
|
||||
let parent_name = if let Some(pos) = partition.name.rfind('p') {
|
||||
if partition
|
||||
.name
|
||||
.chars()
|
||||
.nth(pos + 1)
|
||||
.is_some_and(|c| c.is_ascii_digit())
|
||||
{
|
||||
&partition.name[..pos]
|
||||
} else {
|
||||
partition.name.trim_end_matches(char::is_numeric)
|
||||
}
|
||||
} else {
|
||||
partition.name.trim_end_matches(char::is_numeric)
|
||||
};
|
||||
|
||||
// Find where to insert this partition (after its parent)
|
||||
if let Some(parent_idx) = disks.iter().position(|d| d.name == parent_name) {
|
||||
// Insert after parent and any existing partitions of that parent
|
||||
let mut insert_idx = parent_idx + 1;
|
||||
while insert_idx < disks.len()
|
||||
&& disks[insert_idx].is_partition
|
||||
&& disks[insert_idx].name.starts_with(parent_name)
|
||||
{
|
||||
insert_idx += 1;
|
||||
}
|
||||
disks.insert(insert_idx, partition);
|
||||
} else {
|
||||
// Parent not found (shouldn't happen), just add at end
|
||||
disks.push(partition);
|
||||
}
|
||||
} else {
|
||||
// Not a partition (e.g., zram0), add at end
|
||||
disks.push(partition);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut cache = state.cache_disks.lock().await;
|
||||
cache.set(disks.clone());
|
||||
|
||||
@@ -9,6 +9,8 @@ pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
pub temperature: Option<f32>,
|
||||
pub is_partition: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -15,6 +15,10 @@ pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub total: u64,
|
||||
pub available: u64,
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub is_partition: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
Reference in New Issue
Block a user